From 0616f9b00649e608c2fc9588c1fac3d8caf32c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 15:42:12 -0400 Subject: [PATCH 01/17] New Canvas presets in a More menu; every save writes QuickLook/Preview.jpg (13.7) --- docs/writing-comp-files.md | 2 + src/IO/ImageExporter.cpp | 33 ++++++++ src/IO/ImageExporter.h | 3 + src/IO/ProjectController.cpp | 3 +- src/IO/ProjectStore.cpp | 9 +- src/IO/ProjectStore.h | 7 +- src/UI/NewCanvasSheet.cpp | 99 +++++++++++++++++++++- src/UI/NewCanvasSheet.h | 15 ++++ tests/NewCanvasSheetTests.cpp | 107 ++++++++++++++++++++++++ tests/ProjectReplaceTests.cpp | 4 + tests/QuickLookPreviewTests.cpp | 144 ++++++++++++++++++++++++++++++++ tests/Tests.cmake | 1 + 12 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 tests/QuickLookPreviewTests.cpp diff --git a/docs/writing-comp-files.md b/docs/writing-comp-files.md index 57527ce..3f0632f 100644 --- a/docs/writing-comp-files.md +++ b/docs/writing-comp-files.md @@ -83,6 +83,8 @@ To change an existing layer, keep its `id` and overwrite its PNG, then rewrite t Remove images you no longer reference once the manifest no longer lists them. +A project OmaPhoto saved also has a `QuickLook` folder (`Preview.jpg`), which Finder shows as its Space-bar preview on a Mac. When you change a project, delete that folder, so Finder doesn't preview an out-of-date picture; OmaPhoto writes it again the next time it saves. + ## What the open app does - It reloads about a third of a second after writes stop. Several writes in quick succession arrive as one update, so pause briefly between steps if a viewer should see each one. diff --git a/src/IO/ImageExporter.cpp b/src/IO/ImageExporter.cpp index 311e1dd..bfb2700 100644 --- a/src/IO/ImageExporter.cpp +++ b/src/IO/ImageExporter.cpp @@ -45,6 +45,24 @@ QByteArray encode(QImage image, const char *format, int quality, double resoluti throw ExportError(ExportError::Kind::encode); return data; } + +QByteArray scaledJPEG(const QImage &image, double longSide) +try { + const double scale = std::min(1.0, longSide / std::max(image.width(), image.height())); + const QSize size(std::max(1, int(std::round(image.width() * scale))), std::max(1, int(std::round(image.height() * scale)))); + const QImage scaled = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + QImage flattened(size, QImage::Format_RGB888); + if (scaled.isNull() || flattened.isNull()) + throw ExportError(ExportError::Kind::render); + flattened.fill(Qt::white); + { + QPainter painter(&flattened); + painter.drawImage(QRectF(flattened.rect()), scaled); + } + return encode(flattened, "jpeg", 80, 72); +} catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); +} } ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kind(kind) {} @@ -118,6 +136,21 @@ try { throw ExportError(ExportError::Kind::render); } +std::optional ImageExporter::quickLookImages(const ProjectSnapshot &snapshot) +{ + if (snapshot.manifest.width * snapshot.manifest.height > 50'000'000) { + qCInfo(lcIO) << "no Quick Look preview for" << snapshot.manifest.width << "x" << snapshot.manifest.height; + return std::nullopt; + } + try { + return QuickLookImages{scaledJPEG(render(snapshot).image, 1024)}; + } catch (const std::runtime_error &error) { + // Swift's try? saves without one; the log says why. + qCWarning(lcIO) << "no Quick Look preview:" << error.what(); + return std::nullopt; + } +} + QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot) { const ExportRaster raster = render(snapshot); diff --git a/src/IO/ImageExporter.h b/src/IO/ImageExporter.h index 330b04e..3976b15 100644 --- a/src/IO/ImageExporter.h +++ b/src/IO/ImageExporter.h @@ -3,6 +3,7 @@ #include #include #include +#include #include class ExportError : public std::runtime_error { @@ -39,6 +40,8 @@ struct JPEGResult { namespace ImageExporter { ExportRaster render(const ProjectSnapshot &snapshot); +// Flattened on white, 1024 a side; none past 50 megapixels. +std::optional quickLookImages(const ProjectSnapshot &snapshot); QByteArray pngData(const ProjectSnapshot &snapshot); // Stops at Swift's three points once `cancelled` says so. JPEGResult jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function &cancelled = {}); diff --git a/src/IO/ProjectController.cpp b/src/IO/ProjectController.cpp index be27a4f..e8731af 100644 --- a/src/IO/ProjectController.cpp +++ b/src/IO/ProjectController.cpp @@ -1,6 +1,7 @@ #include "IO/ProjectController.h" #include "IO/RecentProjects.h" #include "Document/ProjectWorkspace.h" +#include "IO/ImageExporter.h" #include "IO/ProjectStore.h" #include "Logging.h" #include @@ -199,7 +200,7 @@ void ProjectController::write(const Prepared &prepared, std::functionsetFuture(QtConcurrent::run([snapshot, destination]() -> std::optional { try { - ProjectStore::save(snapshot, destination); + ProjectStore::save(snapshot, destination, ImageExporter::quickLookImages(snapshot)); return std::nullopt; } catch (const std::runtime_error &error) { return QString::fromUtf8(error.what()); diff --git a/src/IO/ProjectStore.cpp b/src/IO/ProjectStore.cpp index 2fb358c..39a6ffa 100644 --- a/src/IO/ProjectStore.cpp +++ b/src/IO/ProjectStore.cpp @@ -276,7 +276,7 @@ ProjectError::ProjectError(Kind kind, std::optional version, const QStri { } -void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path) +void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path, const std::optional &quickLook) { validate(snapshot.manifest); std::vector> files; @@ -323,6 +323,13 @@ void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path) for (const auto &[filename, data] : files) writeFile(staged + QLatin1String("/images/") + filename, data); syncDirectory(staged + QLatin1String("/images")); + // Quick Look's Space-bar preview; loading ignores it. + if (quickLook) { + if (!QDir().mkdir(staged + QLatin1String("/QuickLook"))) + fail(QStringLiteral("could not create"), staged + QLatin1String("/QuickLook"), QStringLiteral("the folder cannot be made")); + writeFile(staged + QLatin1String("/QuickLook/Preview.jpg"), quickLook->preview); + syncDirectory(staged + QLatin1String("/QuickLook")); + } syncDirectory(staged); replace(staged, destination.absoluteFilePath(), destination.absolutePath(), staging.keep); qCInfo(lcIO) << "saved" << destination.absoluteFilePath() << "with" << snapshot.manifest.layers.size() << "layers and" << files.size() << "images"; diff --git a/src/IO/ProjectStore.h b/src/IO/ProjectStore.h index d7dd8ec..2dfa53c 100644 --- a/src/IO/ProjectStore.h +++ b/src/IO/ProjectStore.h @@ -97,9 +97,14 @@ private: ProjectError(Kind kind, std::optional version, const QString &description); }; +// Swift's, in ImageExporter.swift; here, as the exporter includes the store. +struct QuickLookImages { + QByteArray preview; +}; + // A .comp project: a directory holding manifest.json and images/. namespace ProjectStore { // Replaces the project in one step or leaves it untouched. -void save(const ProjectSnapshot &snapshot, const QString &path); +void save(const ProjectSnapshot &snapshot, const QString &path, const std::optional &quickLook = std::nullopt); ProjectSnapshot load(const QString &path); } diff --git a/src/UI/NewCanvasSheet.cpp b/src/UI/NewCanvasSheet.cpp index 00b9312..51bdf0a 100644 --- a/src/UI/NewCanvasSheet.cpp +++ b/src/UI/NewCanvasSheet.cpp @@ -5,9 +5,13 @@ #include #include #include +#include #include +#include +#include #include #include +#include namespace { QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent) @@ -20,12 +24,58 @@ QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::C label->setForegroundRole(role); return label; } + +// Swift's More button: three dots drawn exactly, flush right. +class MoreButton : public QToolButton { +public: + using QToolButton::QToolButton; + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(palette().color(QPalette::WindowText)); + const double dot = 2.5, top = (height() - 5 * dot) / 2; + for (int index = 0; index < 3; ++index) + painter.drawEllipse(QRectF(width() - dot, top + 2 * dot * index, dot, dot)); + } +}; } +const std::vector> CanvasPreset::groups = { + { + {QStringLiteral("4K"), 3840, 2160}, + {QStringLiteral("1440p"), 2560, 1440}, + {QStringLiteral("1080p"), 1920, 1080}, + }, + { + {QStringLiteral("iPhone 18 Pro"), 1206, 2622}, + {QStringLiteral("iPhone 18 Pro Max"), 1320, 2868}, + {QStringLiteral("MacBook Pro 14\""), 3024, 1964}, + {QStringLiteral("MacBook Pro 16\""), 3456, 2234}, + {QStringLiteral("Studio Display"), 5120, 2880}, + }, + { + {QStringLiteral("Instagram Square"), 1080, 1080}, + {QStringLiteral("Instagram Portrait"), 1080, 1350}, + {QStringLiteral("Instagram Story"), 1080, 1920}, + {QStringLiteral("YouTube Thumb"), 1080, 608}, + }, +}; + +const std::vector CanvasPreset::all = [] { + std::vector presets; + for (const std::vector &group : groups) + presets.insert(presets.end(), group.begin(), group.end()); + return presets; +}(); + NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function onCreate, std::function onOpen, QWidget *parent) : QWidget(parent), m_session(session), m_onCreate(std::move(onCreate)), m_width(new QLineEdit(QStringLiteral("1920"), this)), m_height(new QLineEdit(QStringLiteral("1080"), this)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)), - m_create(new QPushButton(QStringLiteral("Create canvas"), this)) + m_create(new QPushButton(QStringLiteral("Create canvas"), this)), m_presets(new MoreButton(this)) { // Swift's fields stretch the sheet to its full 500 points. setFixedWidth(500); @@ -62,17 +112,46 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::functionsetContentsMargins(28, 28, 28, 28); column->setSpacing(24); - auto *heading = new QVBoxLayout; - heading->setSpacing(6); + auto *heading = new QHBoxLayout; heading->addWidget(text(QStringLiteral("New canvas"), 17, QFont::DemiBold, QPalette::WindowText, this)); - heading->addWidget(text(QStringLiteral("A blank space for your next composition."), 13, QFont::Normal, QPalette::PlaceholderText, this)); + heading->addStretch(1); + heading->addWidget(m_presets); column->addLayout(heading); column->addLayout(fields); column->addWidget(m_note); column->addLayout(buttons); + m_presets->setObjectName(QStringLiteral("presetSizes")); + m_presets->setFixedSize(28, 28); + m_presets->setPopupMode(QToolButton::InstantPopup); + m_presets->setToolTip(QStringLiteral("Preset sizes for screens and common formats")); + m_presets->setAccessibleName(QStringLiteral("Preset sizes")); + auto *menu = new QMenu(m_presets); + auto *choices = new QActionGroup(menu); + // Custom changes nothing; the match stays checked. + QAction *custom = choices->addAction(menu->addAction(QStringLiteral("Custom"))); + custom->setCheckable(true); + custom->setData(-1); + connect(custom, &QAction::triggered, this, &NewCanvasSheet::checkPreset); + int index = 0; + for (const std::vector &group : CanvasPreset::groups) { + menu->addSeparator(); + for (const CanvasPreset &preset : group) { + QAction *action = choices->addAction(menu->addAction(preset.title)); + action->setCheckable(true); + action->setData(index++); + connect(action, &QAction::triggered, this, [this, preset] { + m_width->setText(QString::number(preset.width)); + m_height->setText(QString::number(preset.height)); + }); + } + } + m_presets->setMenu(menu); + connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::validate); connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::validate); + connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset); + connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset); // Return anywhere on the sheet is the default action. std::vector returns; for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter}) @@ -90,6 +169,18 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::functiontext() && QString::number(preset.height) == m_height->text(); + }); + const QVariant index(matched == CanvasPreset::all.end() ? -1 : int(matched - CanvasPreset::all.begin())); + for (QAction *action : m_presets->menu()->actions()) + if (action->data() == index) + action->setChecked(true); } void NewCanvasSheet::synchronize() diff --git a/src/UI/NewCanvasSheet.h b/src/UI/NewCanvasSheet.h index 55ef78c..a147908 100644 --- a/src/UI/NewCanvasSheet.h +++ b/src/UI/NewCanvasSheet.h @@ -4,8 +4,20 @@ #include #include #include +#include #include #include +#include + +// New Canvas sizes in pixels, upright as held. +struct CanvasPreset { + QString title; + int width; + int height; + // Resolutions, Apple screens, then social formats; the menu divides them. + static const std::vector> groups; + static const std::vector all; +}; // The welcome: a new canvas, a project or an image. class NewCanvasSheet : public QWidget { @@ -23,6 +35,8 @@ private: void synchronize(); void validate(); void create(); + // Checks the preset the fields match, else Custom. + void checkPreset(); EditorSession &m_session; const std::function m_onCreate; @@ -30,5 +44,6 @@ private: QLineEdit *const m_height; QLabel *const m_note; QPushButton *const m_create; + QToolButton *const m_presets; bool m_suggestedClipboardSize = false; }; diff --git a/tests/NewCanvasSheetTests.cpp b/tests/NewCanvasSheetTests.cpp index 531f93b..8233c4f 100644 --- a/tests/NewCanvasSheetTests.cpp +++ b/tests/NewCanvasSheetTests.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include // The welcome sheet: sizes, its three ways on, the clipboard. @@ -16,6 +17,31 @@ struct Sheet { QLineEdit &height = *sheet.findChild("heightInput"); QLabel ¬e = *sheet.findChild("canvasNote"); QPushButton &create = *sheet.findChild("createCanvas"); + QToolButton &presets = *sheet.findChild("presetSizes"); + + // The menu's entries, a separator as "-". + QStringList entries() const + { + QStringList titles; + for (const QAction *action : presets.menu()->actions()) + titles << (action->isSeparator() ? QStringLiteral("-") : action->text()); + return titles; + } + QString checked() const + { + QStringList titles; + for (const QAction *action : presets.menu()->actions()) + if (action->isChecked()) + titles << action->text(); + return titles.join(','); + } + QAction &entry(const QString &title) const + { + for (QAction *action : presets.menu()->actions()) + if (action->text() == title) + return *action; + throw std::runtime_error("no such preset"); + } }; QByteArray encoded(const QImage &image, const char *format, QImageIOHandler::Transformations turned = QImageIOHandler::TransformationNone) @@ -42,6 +68,9 @@ private slots: void aCopiedPicturesSizeIsSuggestedOnce(); void theFirstTabSkipsTheClipboardOnce(); void clipboardSizesComeFromHeadersAlone(); + void presetsAreSwiftsGroups(); + void aPresetFillsTheFieldsAndTheMatchIsChecked(); + void theMoreButtonDrawsThreeDotsAndOpensTheMenu(); }; void NewCanvasSheetTests::cleanup() @@ -227,5 +256,83 @@ void NewCanvasSheetTests::clipboardSizesComeFromHeadersAlone() QCOMPARE(NewCanvasSheet::clipboardDimensions(&turned), std::optional(QSize(50, 30))); } +void NewCanvasSheetTests::presetsAreSwiftsGroups() +{ + QStringList sizes; + for (const std::vector &group : CanvasPreset::groups) { + for (const CanvasPreset &preset : group) + sizes << QStringLiteral("%1 %2x%3").arg(preset.title).arg(preset.width).arg(preset.height); + sizes << "-"; + } + QCOMPARE(sizes, (QStringList{"4K 3840x2160", "1440p 2560x1440", "1080p 1920x1080", "-", "iPhone 18 Pro 1206x2622", "iPhone 18 Pro Max 1320x2868", + "MacBook Pro 14\" 3024x1964", "MacBook Pro 16\" 3456x2234", "Studio Display 5120x2880", "-", "Instagram Square 1080x1080", + "Instagram Portrait 1080x1350", "Instagram Story 1080x1920", "YouTube Thumb 1080x608", "-"})); + QCOMPARE(CanvasPreset::all.size(), size_t(12)); + QCOMPARE(CanvasPreset::all.back().title, QString("YouTube Thumb")); + Sheet fixture; + QCOMPARE(fixture.entries(), (QStringList{"Custom", "-", "4K", "1440p", "1080p", "-", "iPhone 18 Pro", "iPhone 18 Pro Max", "MacBook Pro 14\"", + "MacBook Pro 16\"", "Studio Display", "-", "Instagram Square", "Instagram Portrait", "Instagram Story", + "YouTube Thumb"})); + QCOMPARE(fixture.presets.toolTip(), QString("Preset sizes for screens and common formats")); + QCOMPARE(fixture.presets.accessibleName(), QString("Preset sizes")); + QCOMPARE(fixture.presets.size(), QSize(28, 28)); + QCOMPARE(fixture.presets.popupMode(), QToolButton::InstantPopup); +} + +void NewCanvasSheetTests::aPresetFillsTheFieldsAndTheMatchIsChecked() +{ + Sheet fixture; + QCOMPARE(fixture.checked(), QString("1080p")); + fixture.entry("Instagram Story").trigger(); + QCOMPARE(fixture.width.text(), QString("1080")); + QCOMPARE(fixture.height.text(), QString("1920")); + QCOMPARE(fixture.checked(), QString("Instagram Story")); + fixture.entry("Studio Display").trigger(); + QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("5120x2880")); + QCOMPARE(fixture.checked(), QString("Studio Display")); + // A size no preset has is Custom, matched as text. + fixture.height.setText("2881"); + QCOMPARE(fixture.checked(), QString("Custom")); + fixture.height.setText("2880"); + QCOMPARE(fixture.checked(), QString("Studio Display")); + fixture.width.setText("05120"); + QCOMPARE(fixture.checked(), QString("Custom")); + fixture.width.setText("1080"); + fixture.height.setText("608"); + QCOMPARE(fixture.checked(), QString("YouTube Thumb")); + // Custom fills nothing in. + fixture.entry("Custom").trigger(); + QCOMPARE(fixture.width.text() + "x" + fixture.height.text(), QString("1080x608")); + QCOMPARE(fixture.checked(), QString("YouTube Thumb")); + fixture.entry("4K").trigger(); + QCOMPARE(fixture.checked(), QString("4K")); + QVERIFY(fixture.create.isEnabled()); + QTest::mouseClick(&fixture.create, Qt::LeftButton); + QCOMPARE(fixture.created, (QList{QSize(3840, 2160)})); +} + +void NewCanvasSheetTests::theMoreButtonDrawsThreeDotsAndOpensTheMenu() +{ + Sheet fixture; + fixture.sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&fixture.sheet)); + // Flush with the content's right edge, 28 points in. + QCOMPARE(fixture.presets.geometry().right() + 1, 500 - 28); + const QImage image = fixture.presets.grab().toImage(); + const QColor ground = image.pixelColor(2, 2), ink = fixture.presets.palette().color(QPalette::WindowText); + // Dots centred at x 26.75, y 9, 14 and 19. + for (const int y : {9, 14, 19}) + QVERIFY2(std::abs(qGray(image.pixel(26, y)) - qGray(ink.rgb())) < std::abs(qGray(image.pixel(26, y)) - qGray(ground.rgb())), qPrintable(QString::number(y))); + for (const QPoint clear : {QPoint(26, 11), QPoint(26, 16), QPoint(26, 5), QPoint(26, 23), QPoint(22, 14), QPoint(14, 14)}) + QCOMPARE(image.pixelColor(clear), ground); + bool shown = false; + QTimer::singleShot(0, fixture.presets.menu(), [&] { + shown = fixture.presets.menu()->isVisible(); + fixture.presets.menu()->close(); + }); + QTest::mouseClick(&fixture.presets, Qt::LeftButton); + QVERIFY(shown); +} + QTEST_MAIN(NewCanvasSheetTests) #include "NewCanvasSheetTests.moc" diff --git a/tests/ProjectReplaceTests.cpp b/tests/ProjectReplaceTests.cpp index c5bdd4c..da2a0ff 100644 --- a/tests/ProjectReplaceTests.cpp +++ b/tests/ProjectReplaceTests.cpp @@ -119,6 +119,10 @@ void ProjectReplaceTests::everythingIsSyncedBeforeTheSwap() ProjectStore::save(twoLayers(), path); QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync folder", "exchange", "sync folder"})); QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Durable.comp"}); + // The preview and its folder, before the project's folder. + faults.trace.clear(); + ProjectStore::save(twoLayers(), path, QuickLookImages{"preview"}); + QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync file", "sync folder", "sync folder", "exchange", "sync folder"})); } void ProjectReplaceTests::withoutAnExchangeTheProjectIsReplacedInTwoSteps_data() diff --git a/tests/QuickLookPreviewTests.cpp b/tests/QuickLookPreviewTests.cpp new file mode 100644 index 0000000..4a4854b --- /dev/null +++ b/tests/QuickLookPreviewTests.cpp @@ -0,0 +1,144 @@ +#include "DialogDesk.h" +#include "IO/ImageExporter.h" +#include "IO/ProjectController.h" +#include "ProjectFixtures.h" +#include +#include +#include +#include +#include +#include + +// The project's QuickLook/Preview.jpg: written on save, ignored on load. +namespace { +const QDir::Filters everything = QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden; + +// One layer stretched over a canvas of this size. +ProjectSnapshot stretched(int width, int height, const QImage &pixels) +{ + const QUuid id = QUuid::createUuid(); + const ProjectLayerRecord layer{.id = id, .name = "Stretched", .isVisible = true, + .transform = {.origin = {0, 0}, .size = {double(width), double(height)}}, .imageFile = uuidString(id) + ".png"}; + return {.manifest = {.documentID = QUuid::createUuid(), .width = width, .height = height, .activeLayerID = id, .layers = {layer}}, + .images = {{id, asset(pixels, "source")}}}; +} + +QImage decoded(const QByteArray &data) +{ + QImage image = QImage::fromData(data, "jpeg"); + if (image.isNull()) + throw std::runtime_error("the preview is no JPEG"); + return image; +} + +QSize previewSize(int width, int height) +{ + QImage empty(1, 1, QImage::Format_RGBA8888_Premultiplied); + empty.fill(0); + return decoded(ImageExporter::quickLookImages(stretched(width, height, empty)).value().preview).size(); +} +} + +class QuickLookPreviewTests : public QObject { + Q_OBJECT +private slots: + void theStoreWritesThePreviewAndLoadingIgnoresIt(); + void thePreviewIsTheImageOnWhiteAtQualityEighty(); + void theLongSideIsAt1024AndNothingGrows(); + void pastFiftyMegapixelsThereIsNone(); + void aCanvasThatCannotRenderSavesWithoutOne(); + void everySaveWritesIt(); +}; + +void QuickLookPreviewTests::theStoreWritesThePreviewAndLoadingIgnoresIt() +{ + QTemporaryDir root; + const QString path = root.filePath("Previewed.comp"); + const ProjectSnapshot snapshot = twoLayers(); + ProjectStore::save(snapshot, path, QuickLookImages{"not even a JPEG"}); + QCOMPARE(QDir(path).entryList(everything), (QStringList{"images", "manifest.json", "QuickLook"})); + QCOMPARE(QDir(path + "/QuickLook").entryList(everything), QStringList{"Preview.jpg"}); + QCOMPARE(contents(path + "/QuickLook/Preview.jpg"), QByteArray("not even a JPEG")); + // Its bytes are never read. + QCOMPARE(ProjectStore::load(path).manifest.encoded(), snapshot.manifest.encoded()); + // Saved without one, the project has none. + ProjectStore::save(snapshot, path); + QCOMPARE(QDir(path).entryList(everything), (QStringList{"images", "manifest.json"})); + QCOMPARE(ProjectStore::load(path).manifest.encoded(), snapshot.manifest.encoded()); +} + +void QuickLookPreviewTests::thePreviewIsTheImageOnWhiteAtQualityEighty() +{ + // Half opaque red, half clear, over 2048 by 1024. + const ProjectSnapshot snapshot = stretched(2048, 1024, halfRed()); + const QByteArray preview = ImageExporter::quickLookImages(snapshot).value().preview; + const QImage image = decoded(preview); + QCOMPARE(image.size(), QSize(1024, 512)); + QVERIFY(qAbs(image.pixelColor(200, 256).red() - 255) <= 3 && image.pixelColor(200, 256).green() <= 3); + QCOMPARE(image.pixelColor(800, 256), QColor(Qt::white)); + // Byte for byte: smooth scaling, white, quality 80, 72 dpi. + const QImage scaled = ImageExporter::render(snapshot).image.scaled(1024, 512, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + QImage flattened(1024, 512, QImage::Format_RGB888); + flattened.fill(Qt::white); + QPainter(&flattened).drawImage(QRectF(flattened.rect()), scaled); + flattened.setDotsPerMeterX(2835); + flattened.setDotsPerMeterY(2835); + flattened.setColorSpace(QColorSpace::SRgb); + QByteArray expected; + QBuffer buffer(&expected); + QImageWriter writer(&buffer, "jpeg"); + writer.setQuality(80); + QVERIFY(writer.write(flattened)); + QCOMPARE(preview, expected); +} + +void QuickLookPreviewTests::theLongSideIsAt1024AndNothingGrows() +{ + QCOMPARE(previewSize(300, 200), QSize(300, 200)); + QCOMPARE(previewSize(1024, 7), QSize(1024, 7)); + QCOMPARE(previewSize(1025, 1025), QSize(1024, 1024)); + // 1001 × 1024 / 3000 is 341.67: rounded, not cut. + QCOMPARE(previewSize(3000, 1001), QSize(1024, 342)); + QCOMPARE(previewSize(10, 5000), QSize(2, 1024)); + // A sliver keeps a pixel. + QCOMPARE(previewSize(1, 3000), QSize(1, 1024)); + QCOMPARE(previewSize(3000, 1), QSize(1024, 1)); +} + +void QuickLookPreviewTests::pastFiftyMegapixelsThereIsNone() +{ + QImage empty(1, 1, QImage::Format_RGBA8888_Premultiplied); + empty.fill(0); + QVERIFY(!ImageExporter::quickLookImages(stretched(7072, 7071, empty))); + QCOMPARE(decoded(ImageExporter::quickLookImages(stretched(10'000, 5'000, empty)).value().preview).size(), QSize(1024, 512)); +} + +void QuickLookPreviewTests::aCanvasThatCannotRenderSavesWithoutOne() +{ + ProjectSnapshot snapshot = twoLayers(); + snapshot.images.clear(); + QTest::ignoreMessage(QtWarningMsg, "no Quick Look preview: An image inside the project is missing or damaged. The current document has not been replaced."); + QVERIFY(!ImageExporter::quickLookImages(snapshot)); +} + +void QuickLookPreviewTests::everySaveWritesIt() +{ + QTemporaryDir folder; + EditorSession session; + session.createDocument(8, 6); + QImage image(8, 6, QImage::Format_RGBA8888_Premultiplied); + image.fill(Qt::blue); + session.insert(ImportedImage(image, QImage(), "Blue")); + ProjectController controller(session); + DialogDesk desk; + desk.replies = {folder.filePath("Saved")}; + bool saved = false; + controller.save(false, [&](bool done) { saved = done; }); + QTRY_VERIFY(saved); + const QImage preview = decoded(contents(folder.filePath("Saved.comp/QuickLook/Preview.jpg"))); + QCOMPARE(preview.size(), QSize(8, 6)); + QVERIFY(preview.pixelColor(4, 3).blue() >= 250 && preview.pixelColor(4, 3).red() <= 5); +} + +QTEST_MAIN(QuickLookPreviewTests) +#include "QuickLookPreviewTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 80012e2..ea6e3a5 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -203,6 +203,7 @@ compositor_test(KeyboardZoomTests) add_dependencies(ProjectWorkspaceViewTests omaphoto) compositor_test(ProjectSessionTests) compositor_test(ProjectTests) +compositor_test(QuickLookPreviewTests) compositor_test(RasterSnapshotSessionTests) compositor_test(RasterSnapshotTests) compositor_test(SessionGateTests) From 3f2b99f045f45998af0660b4cc57560a97a76b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 15:59:07 -0400 Subject: [PATCH 02/17] Project tabs drag to reorder; tabs that don't fit gather in a menu (13.14) --- CMakeLists.txt | 2 + src/Document/ProjectWorkspace.cpp | 15 ++ src/Document/ProjectWorkspace.h | 2 + src/UI/ProjectTabLayout.cpp | 58 ++++++ src/UI/ProjectTabLayout.h | 41 ++++ src/UI/ProjectTabs+Strip.cpp | 213 +++++++++++++++++++++ src/UI/ProjectTabs.cpp | 158 +++++++++------- src/UI/ProjectTabs.h | 71 ++++++- src/UI/ProjectWorkspaceView.cpp | 5 +- tests/ProjectTabDragTests.cpp | 278 ++++++++++++++++++++++++++++ tests/ProjectTabLayoutTests.cpp | 152 +++++++++++++++ tests/ProjectTabStripTests.cpp | 41 ---- tests/ProjectWorkspaceTests.cpp | 32 ++++ tests/ProjectWorkspaceViewTests.cpp | 25 ++- tests/Tests.cmake | 2 + 15 files changed, 957 insertions(+), 138 deletions(-) create mode 100644 src/UI/ProjectTabLayout.cpp create mode 100644 src/UI/ProjectTabLayout.h create mode 100644 src/UI/ProjectTabs+Strip.cpp create mode 100644 tests/ProjectTabDragTests.cpp create mode 100644 tests/ProjectTabLayoutTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8983403..afb2fc5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -156,6 +156,8 @@ add_library(compositor_core STATIC src/UI/RawDevelopSheet.cpp src/UI/OmarchyTheme.cpp src/UI/ProjectTabs.cpp + src/UI/ProjectTabs+Strip.cpp + src/UI/ProjectTabLayout.cpp src/UI/ProjectWorkspaceView.cpp src/UI/ToolHeaderStyle.cpp src/UI/ToolIcons.cpp diff --git a/src/Document/ProjectWorkspace.cpp b/src/Document/ProjectWorkspace.cpp index 6fe8a5f..d54f56a 100644 --- a/src/Document/ProjectWorkspace.cpp +++ b/src/Document/ProjectWorkspace.cpp @@ -4,6 +4,7 @@ #include #include #include +#include const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row"); @@ -96,6 +97,20 @@ void ProjectWorkspace::select(QUuid id) emit changed(); } +void ProjectWorkspace::moveTab(QUuid id, int index) +{ + const auto from = std::ranges::find_if(m_tabs, [id](const std::shared_ptr &tab) { return tab->id == id; }); + if (from == m_tabs.end()) + return; + const auto target = std::clamp(index, 0, std::ptrdiff_t(m_tabs.size()) - 1); + if (target == from - m_tabs.begin()) + return; + const std::shared_ptr tab = *from; + m_tabs.erase(from); + m_tabs.insert(m_tabs.begin() + target, tab); + emit changed(); +} + void ProjectWorkspace::newCanvas() { if (!canSwitch()) diff --git a/src/Document/ProjectWorkspace.h b/src/Document/ProjectWorkspace.h index f0609c7..e9795a1 100644 --- a/src/Document/ProjectWorkspace.h +++ b/src/Document/ProjectWorkspace.h @@ -36,6 +36,8 @@ public: bool canSwitch() const; ProjectTab &addTab(bool reuseEmpty = true); void select(QUuid id); + // A dragged tab lands at `index`, clamped; chrome, never undone. + void moveTab(QUuid id, int index); void newCanvas(); // Each `done` runs from the event loop, never inline. void open(std::optional path = std::nullopt, std::function done = {}); diff --git a/src/UI/ProjectTabLayout.cpp b/src/UI/ProjectTabLayout.cpp new file mode 100644 index 0000000..b7c5a59 --- /dev/null +++ b/src/UI/ProjectTabLayout.cpp @@ -0,0 +1,58 @@ +#include "UI/ProjectTabLayout.h" +#include + +double ProjectTabOverflow::contentWidth() const +{ + // A pill always has a tab after it. + return visible.empty() ? 0 : visible.back().x + visible.back().width; +} + +QString projectTabOverflowLabel(int hiddenCount) +{ + return hiddenCount == 1 ? QStringLiteral("1 more tab") : QStringLiteral("%1 more tabs").arg(hiddenCount); +} + +ProjectTabOverflow projectTabOverflow(const std::vector &order, const std::map &widths, QUuid selectedID, + double availableWidth, const std::function &pillWidth) +{ + if (order.empty()) + return {}; + const auto span = [&](auto first, auto last) { + double total = 0; + for (auto id = first; id != last; ++id) + total += widths.at(*id); + // Never asked of no tabs. + return total + projectTabSpacing * double(std::distance(first, last) - 1); + }; + const auto place = [&](const std::vector &ids, double x) { + std::vector placed; + for (const QUuid &id : ids) { + placed.push_back({id, x, widths.at(id)}); + x += widths.at(id) + projectTabSpacing; + } + return placed; + }; + // Not yet measured, or everything fits: no pill. + if (availableWidth <= 0 || span(order.begin(), order.end()) <= availableWidth) + return {place(order, 0), {}, std::nullopt}; + qsizetype shown = qsizetype(order.size()); + while (shown > 1) { + const qsizetype hidden = qsizetype(order.size()) - shown; + if (pillWidth(int(hidden)) + projectTabSpacing + span(order.end() - shown, order.end()) <= availableWidth) + break; + shown -= 1; + } + std::vector visibleIDs(order.end() - shown, order.end()), hiddenIDs(order.begin(), order.end() - shown); + if (const auto selected = std::ranges::find(hiddenIDs, selectedID); selected != hiddenIDs.end()) { + hiddenIDs.erase(selected); + hiddenIDs.push_back(visibleIDs[0]); + visibleIDs[0] = selectedID; + // A wider selected tab: the tabs after it make room. + while (visibleIDs.size() > 1 && pillWidth(int(hiddenIDs.size())) + projectTabSpacing + span(visibleIDs.begin(), visibleIDs.end()) > availableWidth) { + hiddenIDs.push_back(visibleIDs[1]); + visibleIDs.erase(visibleIDs.begin() + 1); + } + } + const double pill = pillWidth(int(hiddenIDs.size())); + return {place(visibleIDs, pill + projectTabSpacing), hiddenIDs, ProjectTabPillSlot{0, pill}}; +} diff --git a/src/UI/ProjectTabLayout.h b/src/UI/ProjectTabLayout.h new file mode 100644 index 0000000..96a835d --- /dev/null +++ b/src/UI/ProjectTabLayout.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Between pills, and after the overflow pill. +inline constexpr double projectTabSpacing = 6; + +// A tab's place in the strip. +struct ProjectTabSlot { + QUuid id; + double x; + double width; + bool operator==(const ProjectTabSlot &) const = default; +}; + +// The overflow pill's place: it is no tab. +struct ProjectTabPillSlot { + double x; + double width; + bool operator==(const ProjectTabPillSlot &) const = default; +}; + +// What the strip draws: tabs that fit, the rest. +struct ProjectTabOverflow { + std::vector visible; + std::vector hiddenIDs; + std::optional pill; + double contentWidth() const; + bool operator==(const ProjectTabOverflow &) const = default; +}; + +// "N more tabs", singular for one. +QString projectTabOverflowLabel(int hiddenCount); + +// Drops tabs from the front until the rest fit. +ProjectTabOverflow projectTabOverflow(const std::vector &order, const std::map &widths, QUuid selectedID, + double availableWidth, const std::function &pillWidth); diff --git a/src/UI/ProjectTabs+Strip.cpp b/src/UI/ProjectTabs+Strip.cpp new file mode 100644 index 0000000..bc1c05f --- /dev/null +++ b/src/UI/ProjectTabs+Strip.cpp @@ -0,0 +1,213 @@ +#include "UI/ProjectTabs.h" +#include +#include +#include + +ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent) + : QWidget(parent), m_workspace(workspace), m_pill(new OverflowTabsPill(workspace, this)) +{ + setObjectName(QStringLiteral("projectTabs")); + setAccessibleName(QStringLiteral("Project tabs")); + setFixedHeight(34); + // The toolbar's free width; the toolbar shows through. + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize); + synchronize(); +} + +// Every tab in a row, as if unmeasured. +QSize ProjectTabStrip::sizeHint() const +{ + std::vector order; + for (const std::shared_ptr &tab : m_workspace.tabs()) + order.push_back(tab->id); + return {int(std::ceil(projectTabOverflow(order, widths(), m_workspace.selectedID(), 0, &OverflowTabsPill::pillWidth).contentWidth())), 34}; +} + +QSize ProjectTabStrip::minimumSizeHint() const +{ + return {0, 34}; +} + +QList ProjectTabStrip::buttons() const +{ + return QList(m_buttons.begin(), m_buttons.end()); +} + +void ProjectTabStrip::synchronize() +{ + // Buttons follow the tabs: kept where the tab stays. + std::vector kept; + for (const std::shared_ptr &tab : m_workspace.tabs()) { + const auto found = std::ranges::find(m_buttons, tab, &ProjectTabButton::tab); + ProjectTabButton *button = nullptr; + if (found == m_buttons.end()) { + const QUuid id = tab->id; + button = new ProjectTabButton(m_workspace, tab, [this, id](TabDragPhase phase, double translation) { handleReorder(id, phase, translation); }, this); + // A title or dot changes the widths. + connect(&tab->session, &EditorSession::changed, button, [this] { layOut(); }); + } else { + button = *found; + m_buttons.erase(found); + } + button->synchronize(); + kept.push_back(button); + } + for (ProjectTabButton *gone : m_buttons) + delete gone; + m_buttons = std::move(kept); + // A drag whose tab closed under it is over. + if (m_reorder && !m_workspace.tab(m_reorder->id)) + m_reorder.reset(); + layOut(); +} + +std::map ProjectTabStrip::widths() const +{ + std::map result; + for (const ProjectTabButton *button : m_buttons) + result.insert({button->tab->id, button->sizeHint().width()}); + return result; +} + +ProjectTabOverflow ProjectTabStrip::overflow() const +{ + std::vector order; + for (const std::shared_ptr &tab : m_workspace.tabs()) + order.push_back(tab->id); + return projectTabOverflow(order, widths(), m_workspace.selectedID(), width(), &OverflowTabsPill::pillWidth); +} + +void ProjectTabStrip::layOut(bool animated) +{ + const ProjectTabOverflow layout = overflow(); + m_pill->setVisible(layout.pill.has_value()); + if (layout.pill) { + m_pill->setHiddenIDs(layout.hiddenIDs); + m_pill->setGeometry(int(layout.pill->x), 3, int(layout.pill->width), 28); + } + for (ProjectTabButton *button : m_buttons) { + const auto slot = std::ranges::find(layout.visible, button->tab->id, &ProjectTabSlot::id); + button->setVisible(slot != layout.visible.end()); + if (slot == layout.visible.end()) + continue; + button->resize(int(slot->width), 28); + const QPoint target(int(std::round(renderX(*slot, layout.contentWidth()))), 3); + // The dragged tab follows the pointer; the others ease aside. + auto *slide = button->findChild(QString(), Qt::FindDirectChildrenOnly); + const bool eases = animated && !(m_reorder && m_reorder->id == button->tab->id); + if (slide && (!eases || slide->endValue() != QVariant(target))) + delete std::exchange(slide, nullptr); + if (!eases) + button->move(target); + else if (!slide && button->pos() != target) { + slide = new QPropertyAnimation(button, "pos", button); + slide->setDuration(150); + slide->setEasingCurve(QEasingCurve::OutQuad); + slide->setEndValue(target); + slide->start(QAbstractAnimation::DeleteWhenStopped); + } + } + updateGeometry(); +} + +double ProjectTabStrip::renderX(const ProjectTabSlot &slot, double contentWidth) const +{ + if (!m_reorder) + return slot.x; + if (slot.id == m_reorder->id) + return std::min(std::max(m_reorder->originX + m_reorder->translation, m_reorder->startX), std::max(m_reorder->startX, contentWidth - slot.width)); + const auto index = std::ranges::find(m_reorder->others, slot.id); + if (index == m_reorder->others.end()) + return slot.x; + const double x = m_reorder->compactedX.at(slot.id); + return index - m_reorder->others.begin() >= m_reorder->targetIndex ? x + m_reorder->widths.at(m_reorder->id) + projectTabSpacing : x; +} + +// The gap nearest the dragged tab: where it lands. +int ProjectTabStrip::Reorder::nearestSlot() const +{ + const double x = originX + translation; + std::vector gaps; + for (const QUuid &other : others) + gaps.push_back(compactedX.at(other)); + gaps.push_back(others.empty() ? startX : compactedX.at(others.back()) + widths.at(others.back()) + projectTabSpacing); + return int(std::ranges::min_element(gaps, {}, [x](double slot) { return std::abs(slot - x); }) - gaps.begin()); +} + +void ProjectTabStrip::handleReorder(QUuid id, TabDragPhase phase, double translation) +{ + if (phase == TabDragPhase::changed) { + // Another tab's drag lost its release, as Swift's timer finds. + if (m_reorder && m_reorder->id != id) + m_reorder.reset(); + if (!m_reorder) { + if (!m_workspace.canSwitch() || std::abs(translation) < 3) + return; + // Dragging a tab selects it, as in Safari. + m_workspace.select(id); + m_reorder = makeReorder(id); + // Above the tabs it crosses. + for (ProjectTabButton *button : m_buttons) { + if (button->tab->id == id) + button->raise(); + } + } + m_reorder->translation = translation; + m_reorder->targetIndex = m_reorder->nearestSlot(); + layOut(true); + return; + } + // Busy by the drop: the tabs go back. + if (m_reorder && m_reorder->id == id && m_workspace.canSwitch()) + commitReorder(*m_reorder); + m_reorder.reset(); + layOut(true); +} + +ProjectTabStrip::Reorder ProjectTabStrip::makeReorder(QUuid id) const +{ + const ProjectTabOverflow layout = overflow(); + Reorder state{.id = id, .others = {}, .widths = widths(), .compactedX = {}, .startX = layout.visible.empty() ? 0 : layout.visible.front().x, + .originX = 0}; + double x = state.startX; + state.originX = state.startX; + for (const ProjectTabSlot &slot : layout.visible) { + if (slot.id == id) { + state.originX = slot.x; + continue; + } + state.others.push_back(slot.id); + state.compactedX.insert({slot.id, x}); + x += state.widths.at(slot.id) + projectTabSpacing; + } + return state; +} + +// Only the tabs visible as the drag began ever move. +void ProjectTabStrip::commitReorder(const Reorder &state) +{ + std::vector order; + for (const std::shared_ptr &tab : m_workspace.tabs()) + order.push_back(tab->id); + // A tab closed mid-drag has no place. + const auto position = [&order](QUuid id) -> std::optional { + const auto found = std::ranges::find(order, id); + return found == order.end() ? std::nullopt : std::optional(found - order.begin()); + }; + const qsizetype from = position(state.id).value(); + qsizetype target = from; + if (const auto neighbor = state.targetIndex < int(state.others.size()) ? position(state.others[size_t(state.targetIndex)]) : std::nullopt) + target = *neighbor; + else if (const auto last = state.others.empty() ? std::nullopt : position(state.others.back())) + target = *last + 1; + if (from < target) + target -= 1; + m_workspace.moveTab(state.id, int(target)); +} + +void ProjectTabStrip::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + layOut(); +} diff --git a/src/UI/ProjectTabs.cpp b/src/UI/ProjectTabs.cpp index 5475051..77f9cfd 100644 --- a/src/UI/ProjectTabs.cpp +++ b/src/UI/ProjectTabs.cpp @@ -6,11 +6,12 @@ #include #include #include +#include +#include #include -#include +#include #include #include -#include namespace { QFont tabFont(bool active) @@ -47,8 +48,10 @@ int labelWidth(const QString &title, bool active, bool modified) } } -ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr tab, QWidget *parent) - : QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new TabTitle(this)), m_close(new QToolButton(this)) +ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr tab, std::function onReorder, + QWidget *parent) + : QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_onReorder(std::move(onReorder)), m_select(new TabTitle(this)), + m_close(new QToolButton(this)) { // 28 high: the strip's 34 less its margins. m_select->setObjectName(QStringLiteral("selectTab")); @@ -65,6 +68,7 @@ ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr< row->addWidget(m_select); row->addWidget(m_close); setAcceptDrops(true); + m_select->installEventFilter(this); connect(m_select, &QToolButton::clicked, this, [this] { m_workspace.select(this->tab->id); }); connect(m_close, &QToolButton::clicked, this, [this] { m_workspace.close(this->tab->id); }); // The strip syncs every button on the workspace's news. @@ -93,6 +97,26 @@ void ProjectTabButton::synchronize() setProperty("active", active); } +// A press moved three points reorders; measured in the window. +bool ProjectTabButton::eventFilter(QObject *watched, QEvent *event) +{ + const auto *mouse = static_cast(event); + if (event->type() == QEvent::MouseButtonPress && mouse->button() == Qt::LeftButton) { + m_press = mouse->globalPosition(); + m_dragging = false; + } else if (event->type() == QEvent::MouseMove && m_press && mouse->buttons().testFlag(Qt::LeftButton)) { + m_dragging = m_dragging || QLineF(*m_press, mouse->globalPosition()).length() >= 3; + if (m_dragging) + m_onReorder(TabDragPhase::changed, mouse->globalPosition().x() - m_press->x()); + } else if (event->type() == QEvent::MouseButtonRelease && mouse->button() == Qt::LeftButton && m_press) { + const double translation = mouse->globalPosition().x() - m_press->x(); + m_press.reset(); + if (std::exchange(m_dragging, false)) + m_onReorder(TabDragPhase::ended, translation); + } + return QWidget::eventFilter(watched, event); +} + // Another tab's layer, unless Alt duplicates it, or files. bool ProjectTabButton::acceptsDrop(const QMimeData &data) const { @@ -237,82 +261,74 @@ void NewCanvasButton::dropEvent(QDropEvent *event) m_workspace.receiveProviders(*event->mimeData(), std::nullopt); } -ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent) - : QScrollArea(parent), m_workspace(workspace), m_row(new QWidget(this)) +OverflowTabsPill::OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent) : QToolButton(parent), m_workspace(workspace) { - setObjectName(QStringLiteral("projectTabs")); - setAccessibleName(QStringLiteral("Project tabs")); - setFixedHeight(34); - setFrameShape(QFrame::NoFrame); - // The toolbar shows through; a stylesheet would freeze the palette. - setAutoFillBackground(false); - viewport()->setAutoFillBackground(false); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - auto *row = new QHBoxLayout(m_row); - row->setContentsMargins(0, 3, 0, 3); - row->setSpacing(6); - row->addStretch(1); - setWidget(m_row); - // setWidget turns the row's fill on; the toolbar shows through. - m_row->setAutoFillBackground(false); - m_row->installEventFilter(this); - connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize); - synchronize(); + setObjectName(QStringLiteral("projectTabsOverflow")); + setFocusPolicy(Qt::NoFocus); + QFont font; + font.setPixelSize(12); + font.setWeight(QFont::Medium); + setFont(font); + connect(this, &QToolButton::pressed, this, &OverflowTabsPill::showMenu); } -QList ProjectTabStrip::buttons() const +double OverflowTabsPill::pillWidth(int hiddenCount) { - QList result; - for (int index = 0; index < m_row->layout()->count(); ++index) { - if (auto *button = qobject_cast(m_row->layout()->itemAt(index)->widget())) - result << button; - } - return result; + QFont font; + font.setPixelSize(12); + font.setWeight(QFont::Medium); + // Leading, gap before the chevron, chevron, trailing. + return std::ceil(QFontMetricsF(font).horizontalAdvance(projectTabOverflowLabel(hiddenCount))) + 11 + 4 + 10 + 11; } -void ProjectTabStrip::synchronize() +void OverflowTabsPill::setHiddenIDs(std::vector hiddenIDs) { - // Buttons follow the tabs: kept where the tab stays. - auto *row = static_cast(m_row->layout()); - QList shown = buttons(); - int position = 0; - for (const std::shared_ptr &tab : m_workspace.tabs()) { - const auto kept = std::find_if(shown.begin(), shown.end(), [&](ProjectTabButton *button) { return button->tab == tab; }); - ProjectTabButton *button = kept == shown.end() ? new ProjectTabButton(m_workspace, tab, m_row) : *kept; - if (kept != shown.end()) - shown.erase(kept); - row->removeWidget(button); - row->insertWidget(position, button); - button->show(); - button->synchronize(); - position += 1; - } - for (ProjectTabButton *gone : shown) - delete gone; - // The row is as wide as its tabs, never squeezed. - m_row->resize(m_row->sizeHint()); - showFront(); + m_hiddenIDs = std::move(hiddenIDs); + const QString label = projectTabOverflowLabel(int(m_hiddenIDs.size())); + setText(label); + setToolTip(label); + setAccessibleName(label); + update(); } -bool ProjectTabStrip::eventFilter(QObject *watched, QEvent *event) +// Below the pill: hidden tabs in order, unsaved marked. +void OverflowTabsPill::showMenu() { - // A title changed size: the row takes its new width. - if (event->type() == QEvent::LayoutRequest) - m_row->resize(m_row->sizeHint()); - return QScrollArea::eventFilter(watched, event); -} - -// Only a new front tab scrolls into view. -void ProjectTabStrip::showFront() -{ - if (std::exchange(m_shownFront, m_workspace.selectedID()) == m_workspace.selectedID()) + setDown(false); + if (!m_workspace.canSwitch()) return; - // Posted, as Swift's: the row's layout request runs first. - QTimer::singleShot(0, this, [this] { - for (ProjectTabButton *button : buttons()) { - if (button->tab->id == m_workspace.selectedID()) - ensureWidgetVisible(button, 0, 0); - } - }); + auto *menu = new QMenu(this); + menu->setObjectName(QStringLiteral("projectTabsOverflowMenu")); + menu->setAttribute(Qt::WA_DeleteOnClose); + for (const QUuid &id : m_hiddenIDs) { + const std::shared_ptr tab = m_workspace.tab(id); + const QString title = (tab->session.isModified() ? QStringLiteral("• ") : QString()) + tab->title(); + connect(menu->addAction(title), &QAction::triggered, this, [this, id] { m_workspace.select(id); }); + } + menu->popup(mapToGlobal(QPoint(0, height() + 4))); +} + +void OverflowTabsPill::paintEvent(QPaintEvent *) +{ + // An inactive tab's capsule; the label dims while busy. + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + QColor fill = palette().color(QPalette::WindowText), edge = fill; + fill.setAlphaF(0.035); + edge.setAlphaF(0.08); + painter.setPen(QPen(edge, 1)); + painter.setBrush(fill); + painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 14, 14); + painter.setOpacity(m_workspace.canSwitch() ? 1 : 0.5); + painter.setPen(palette().color(QPalette::ButtonText)); + painter.drawText(QRectF(11, 0, width() - 36, height()), Qt::AlignLeft | Qt::AlignVCenter, text()); + // Swift's chevron.down, nine points, medium. + QPainterPath chevron; + const double left = width() - 21, middle = height() / 2.0; + chevron.moveTo(left + 1, middle - 2); + chevron.lineTo(left + 5, middle + 2); + chevron.lineTo(left + 9, middle - 2); + painter.setPen(QPen(palette().color(QPalette::ButtonText), 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(chevron); } diff --git a/src/UI/ProjectTabs.h b/src/UI/ProjectTabs.h index 300f4eb..5e12842 100644 --- a/src/UI/ProjectTabs.h +++ b/src/UI/ProjectTabs.h @@ -1,14 +1,18 @@ #pragma once #include "Document/ProjectWorkspace.h" -#include +#include "UI/ProjectTabLayout.h" #include #include +// Swift's TabDragPhase: a reorder drag goes on, or ends. +enum class TabDragPhase { changed, ended }; + // One tab: its title, the unsaved dot, its close button. class ProjectTabButton : public QWidget { Q_OBJECT public: - ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr tab, QWidget *parent = nullptr); + ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr tab, std::function onReorder, + QWidget *parent = nullptr); const std::shared_ptr tab; // What the session and the workspace hold, shown. @@ -16,6 +20,7 @@ public: bool isActive() const; protected: + bool eventFilter(QObject *watched, QEvent *event) override; void paintEvent(QPaintEvent *event) override; void dragEnterEvent(QDragEnterEvent *event) override; void dragMoveEvent(QDragMoveEvent *event) override; @@ -27,9 +32,13 @@ private: void setTargeted(bool targeted); ProjectWorkspace &m_workspace; + const std::function m_onReorder; QToolButton *const m_select; QToolButton *const m_close; bool m_targeted = false; + // Swift's DragGesture: the press, in the window's space. + std::optional m_press; + bool m_dragging = false; }; // Swift's NewProjectDropTarget: the New canvas button takes drops. @@ -53,23 +62,67 @@ private: bool m_targeted = false; }; -// The row of tabs, one a project, scrolling when long. -class ProjectTabStrip : public QScrollArea { +// Swift's OverflowTabsPill: the tabs that do not fit, a menu. +class OverflowTabsPill : public QToolButton { + Q_OBJECT +public: + explicit OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent = nullptr); + + // Swift's projectTabOverflowPillWidth. + static double pillWidth(int hiddenCount); + void setHiddenIDs(std::vector hiddenIDs); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + void showMenu(); + + ProjectWorkspace &m_workspace; + std::vector m_hiddenIDs; +}; + +// The row of tabs; the rest gather in a menu. +class ProjectTabStrip : public QWidget { Q_OBJECT public: explicit ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent = nullptr); QList buttons() const; + OverflowTabsPill *pill() const { return m_pill; } + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; protected: - bool eventFilter(QObject *watched, QEvent *event) override; + void resizeEvent(QResizeEvent *event) override; private: + // Swift's TabReorderState, taken once as the drag starts. + struct Reorder { + QUuid id; + std::vector others; + std::map widths; + std::map compactedX; + double startX; + double originX; + double translation = 0; + int targetIndex = 0; + int nearestSlot() const; + }; + void synchronize(); - void showFront(); + std::map widths() const; + ProjectTabOverflow overflow() const; + // Places the pill and the tabs; others slide when `animated`. + void layOut(bool animated = false); + double renderX(const ProjectTabSlot &slot, double contentWidth) const; + void handleReorder(QUuid id, TabDragPhase phase, double translation); + Reorder makeReorder(QUuid id) const; + void commitReorder(const Reorder &state); ProjectWorkspace &m_workspace; - QWidget *const m_row; - // Swift's lastSelectedID: only a new front tab scrolls. - std::optional m_shownFront; + OverflowTabsPill *const m_pill; + std::vector m_buttons; + std::optional m_reorder; }; diff --git a/src/UI/ProjectWorkspaceView.cpp b/src/UI/ProjectWorkspaceView.cpp index 20fb486..30ee8ff 100644 --- a/src/UI/ProjectWorkspaceView.cpp +++ b/src/UI/ProjectWorkspaceView.cpp @@ -26,11 +26,8 @@ ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget auto *newButton = new NewCanvasButton(m_workspace, m_toolbar); newButton->setDefaultAction(m_newCanvas); m_toolbar->addWidget(newButton); - m_toolbar->addWidget(m_tabs); // The strip takes the toolbar's free width; zooms stay right. - auto *spacer = new QWidget(m_toolbar); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - m_toolbar->addWidget(spacer); + m_toolbar->addWidget(m_tabs); m_fit = action(m_toolbar, QStringLiteral("Fit"), QStringLiteral("Fit canvas in window (Ctrl+0)"), QStringLiteral("fitCanvas")); m_actualPixels = action(m_toolbar, QStringLiteral("100%"), QStringLiteral("Actual pixels (Ctrl+1)"), QStringLiteral("actualPixels")); m_zoomIn = action(m_toolbar, QStringLiteral("+"), QStringLiteral("Zoom in (Ctrl++)"), QStringLiteral("zoomIn")); diff --git a/tests/ProjectTabDragTests.cpp b/tests/ProjectTabDragTests.cpp new file mode 100644 index 0000000..da95801 --- /dev/null +++ b/tests/ProjectTabDragTests.cpp @@ -0,0 +1,278 @@ +#include "UI/ProjectTabs.h" +#include +#include +#include + +// Swift 1.3's strip: tabs drag to reorder; others gather. +namespace { +QToolButton &select(ProjectTabButton &button) +{ + return *button.findChild(QStringLiteral("selectTab")); +} + +QStringList titles(const ProjectWorkspace &workspace) +{ + QStringList result; + for (const std::shared_ptr &tab : workspace.tabs()) + result << tab->title(); + return result; +} + +ProjectTabButton &button(ProjectTabStrip &strip, const QString &title) +{ + for (ProjectTabButton *button : strip.buttons()) { + if (button->tab->title() == title) + return *button; + } + throw std::runtime_error("no such tab"); +} + +// Three tabs, Untitled to Untitled 3, the last in front. +struct Strip { + ProjectWorkspace workspace; + ProjectTabStrip strip{workspace}; + Strip(int tabs = 3, int width = 1000) + { + for (int index = 1; index < tabs; ++index) + workspace.newCanvas(); + strip.resize(width, 34); + strip.show(); + if (!QTest::qWaitForWindowExposed(&strip)) + throw std::runtime_error("the strip never showed"); + } + // QTest sends to a widget, not to the press's grab. + QToolButton *pressed = nullptr; + // Presses a tab; returns the point, in the strip. + QPoint press(const QString &title) + { + ProjectTabButton &tab = button(strip, title); + const QPoint point = tab.mapTo(&strip, QPoint(20, 14)); + pressed = &select(tab); + QTest::mousePress(pressed, Qt::LeftButton, {}, QPoint(20 - pressed->x(), 14)); + return point; + } + void move(QPoint point) { QTest::mouseMove(pressed, pressed->mapFrom(&strip, point)); } + // The release goes to the pressed title wherever it is. + void release(const QString &title, QPoint point) + { + QToolButton &title_ = select(button(strip, title)); + QTest::mouseRelease(&title_, Qt::LeftButton, {}, title_.mapFrom(&strip, point)); + } +}; + +// Every slide finished: tabs rest at their slots. +bool settled(const ProjectTabStrip &strip) +{ + return strip.findChildren().isEmpty(); +} +} + +class ProjectTabDragTests : public QObject { + Q_OBJECT +private slots: + void aDraggedTabFollowsThePointerAndLandsInTheGap(); + void aSmallOrUpwardMoveIsAClick(); + void aBusyProjectHoldsTheOrder(); + void aTabClosedMidDragEndsIt(); + void tabsThatDoNotFitGatherInAMenu(); + void theMenuSwitchesAndPinsTheChosenTab(); + void aBusyProjectOpensNoMenu(); +}; + +void ProjectTabDragTests::aDraggedTabFollowsThePointerAndLandsInTheGap() +{ + Strip fixture; + ProjectTabButton &first = button(fixture.strip, "Untitled"), &second = button(fixture.strip, "Untitled 2"), + &third = button(fixture.strip, "Untitled 3"); + QCOMPARE(second.x(), first.width() + 6); + const QPoint start = fixture.press("Untitled"); + // Two points stay a press; three drag, and select. + fixture.move(start + QPoint(2, 0)); + QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[2]->id); + fixture.move(start + QPoint(40, 0)); + QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id); + QCOMPARE(first.x(), 40); + // Chosen, it is set semibold: widths read now. + const int w1 = first.width(), w2 = second.width(), w3 = third.width(); + // Nearest the second's compacted place still: nothing slides. + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(second.x(), w1 + 6); + // Past the middle of the gap: the second slides left. + fixture.move(start + QPoint(w2 / 2 + 10, 0)); + const auto *slide = second.findChild(); + QVERIFY(slide && slide->duration() == 150 && slide->easingCurve() == QEasingCurve::OutQuad); + QCOMPARE(slide->endValue().toPoint(), QPoint(0, 3)); + QVERIFY(!first.findChild() && !third.findChild()); + // The dragged tab lies over the one it crosses. + QWidget *hit = fixture.strip.childAt(first.x() + 2, 14); + QVERIFY(hit == &first || first.isAncestorOf(hit)); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(second.x(), 0); + QCOMPARE(third.x(), w1 + 6 + w2 + 6); + // Far right: held at the row's end, the others compacted. + fixture.move(start + QPoint(5000, 0)); + QTRY_VERIFY(settled(fixture.strip)); + const int end = w1 + w2 + w3 + 12; + QCOMPARE(first.x(), end - w1); + QCOMPARE(third.x(), w2 + 6); + // Far left: held at the row's start. + fixture.move(start + QPoint(-5000, 0)); + QCOMPARE(first.x(), 0); + fixture.move(start + QPoint(5000, 0)); + QVERIFY(first.isVisible() && first.y() == 3); + fixture.release("Untitled", start + QPoint(5000, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled 3", "Untitled"})); + QCOMPARE(fixture.workspace.selectedID(), first.tab->id); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(second.x(), 0); + QCOMPARE(third.x(), second.width() + 6); + QCOMPARE(first.x(), third.x() + third.width() + 6); + // The front tab, dragged one place left. + const QPoint back = fixture.press("Untitled"); + fixture.move(back - QPoint(third.width() / 2 + 10, 0)); + fixture.release("Untitled", back - QPoint(third.width() / 2 + 10, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled", "Untitled 3"})); + // Let go where it began: the order stays. + const QPoint still = fixture.press("Untitled 3"); + fixture.move(still + QPoint(5, 0)); + fixture.release("Untitled 3", still + QPoint(5, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled", "Untitled 3"})); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(third.x(), second.width() + first.width() + 12); +} + +void ProjectTabDragTests::aSmallOrUpwardMoveIsAClick() +{ + Strip fixture; + const QPoint start = fixture.press("Untitled"); + // Three points up, two across: no drag, no selection yet. + fixture.move(start + QPoint(2, -3)); + QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[2]->id); + QCOMPARE(button(fixture.strip, "Untitled").x(), 0); + fixture.release("Untitled", start + QPoint(2, -3)); + // The click selects; the order stays. + QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"})); + // A busy project starts no drag and selects nothing. + fixture.workspace.current().session.setIsProjectBusy(true); + const QPoint busy = fixture.press("Untitled"); + fixture.move(busy + QPoint(200, 0)); + QCOMPARE(button(fixture.strip, "Untitled").x(), 0); + fixture.release("Untitled", busy + QPoint(200, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"})); +} + +void ProjectTabDragTests::aBusyProjectHoldsTheOrder() +{ + Strip fixture; + const QPoint start = fixture.press("Untitled"); + fixture.move(start + QPoint(5000, 0)); + QVERIFY(button(fixture.strip, "Untitled").x() > 100); + // Busy by the drop: the tabs go back. + fixture.workspace.current().session.setIsProjectBusy(true); + fixture.release("Untitled", start + QPoint(5000, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"})); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(button(fixture.strip, "Untitled").x(), 0); + QCOMPARE(button(fixture.strip, "Untitled 2").x(), button(fixture.strip, "Untitled").width() + 6); +} + +void ProjectTabDragTests::aTabClosedMidDragEndsIt() +{ + Strip fixture; + const QPoint start = fixture.press("Untitled 2"); + fixture.move(start + QPoint(5000, 0)); + QVERIFY(button(fixture.strip, "Untitled 2").x() > 100); + fixture.workspace.removeTab(fixture.workspace.tabs()[1]->id); + QCOMPARE(fixture.strip.buttons().size(), 2); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(button(fixture.strip, "Untitled 3").x(), button(fixture.strip, "Untitled").width() + 6); + // Another drag works at once. + const QPoint next = fixture.press("Untitled 3"); + fixture.move(next - QPoint(5000, 0)); + fixture.release("Untitled 3", next - QPoint(5000, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 3", "Untitled"})); +} + +void ProjectTabDragTests::tabsThatDoNotFitGatherInAMenu() +{ + Strip fixture(12, 400); + OverflowTabsPill &pill = *fixture.strip.pill(); + QVERIFY(pill.isVisible()); + QCOMPARE(pill.objectName(), QString("projectTabsOverflow")); + QList shown; + for (ProjectTabButton *tab : fixture.strip.buttons()) { + if (tab->isVisible()) + shown << tab; + } + const int hidden = 12 - int(shown.size()); + QVERIFY(hidden > 0 && shown.size() >= 2); + QCOMPARE(pill.text(), QStringLiteral("%1 more tabs").arg(hidden)); + QCOMPARE(pill.toolTip(), pill.text()); + QCOMPARE(pill.accessibleName(), pill.text()); + QCOMPARE(pill.geometry(), QRect(0, 3, int(OverflowTabsPill::pillWidth(hidden)), 28)); + // The right-most tabs stay, the front one last. + QCOMPARE(shown.last()->tab->id, fixture.workspace.selectedID()); + QCOMPARE(shown.first()->tab->id, fixture.workspace.tabs()[size_t(hidden)]->id); + QCOMPARE(shown.first()->x(), pill.width() + 6); + QVERIFY(shown.last()->geometry().right() < 400); + // Swift's measure: the label, 11, 4, 10, 11. + QFont font; + font.setPixelSize(12); + font.setWeight(QFont::Medium); + QCOMPARE(OverflowTabsPill::pillWidth(3), std::ceil(QFontMetricsF(font).horizontalAdvance("3 more tabs")) + 36); + // Wide enough, every tab shows and the pill goes. + fixture.strip.resize(3000, 34); + QVERIFY(!pill.isVisible()); + for (ProjectTabButton *tab : fixture.strip.buttons()) + QVERIFY(tab->isVisible()); + QCOMPARE(fixture.strip.buttons().first()->x(), 0); +} + +void ProjectTabDragTests::theMenuSwitchesAndPinsTheChosenTab() +{ + Strip fixture(12, 400); + OverflowTabsPill &pill = *fixture.strip.pill(); + fixture.workspace.tabs()[1]->session.createDocument(8, 8); + QVERIFY(fixture.workspace.tabs()[1]->session.isModified()); + QTest::mousePress(&pill, Qt::LeftButton); + auto *menu = pill.findChild("projectTabsOverflowMenu"); + QVERIFY(menu && menu->isVisible()); + QStringList entries; + for (const QAction *action : menu->actions()) + entries << action->text(); + const int hidden = int(entries.size()); + QCOMPARE(pill.text(), QStringLiteral("%1 more tabs").arg(hidden)); + QCOMPARE(entries.mid(0, 3), (QStringList{"Untitled", "• Untitled 2", "Untitled 3"})); + QCOMPARE(menu->mapToGlobal(QPoint(0, 0)), pill.mapToGlobal(QPoint(0, pill.height() + 4))); + menu->actions()[1]->trigger(); + menu->close(); + QTest::mouseRelease(&pill, Qt::LeftButton); + const QUuid chosen = fixture.workspace.tabs()[1]->id; + QCOMPARE(fixture.workspace.selectedID(), chosen); + // Pinned after the pill; the row stays inside. + ProjectTabButton &pinned = button(fixture.strip, "Untitled 2"); + QVERIFY(pinned.isVisible()); + QCOMPARE(pinned.x(), pill.width() + 6); + for (ProjectTabButton *tab : fixture.strip.buttons()) + QVERIFY(!tab->isVisible() || tab->geometry().right() < 400); + QTRY_VERIFY(!pill.findChild("projectTabsOverflowMenu")); +} + +void ProjectTabDragTests::aBusyProjectOpensNoMenu() +{ + Strip fixture(12, 400); + OverflowTabsPill &pill = *fixture.strip.pill(); + const QImage free = pill.grab().toImage(); + fixture.workspace.current().session.setIsProjectBusy(true); + pill.update(); + // The label dims; the capsule stays. + const QImage busy = pill.grab().toImage(); + QVERIFY(busy != free); + QCOMPARE(busy.pixelColor(pill.width() / 2, 1), free.pixelColor(pill.width() / 2, 1)); + QTest::mouseClick(&pill, Qt::LeftButton); + QVERIFY(!pill.findChild("projectTabsOverflowMenu")); +} + +QTEST_MAIN(ProjectTabDragTests) +#include "ProjectTabDragTests.moc" diff --git a/tests/ProjectTabLayoutTests.cpp b/tests/ProjectTabLayoutTests.cpp new file mode 100644 index 0000000..f3ba070 --- /dev/null +++ b/tests/ProjectTabLayoutTests.cpp @@ -0,0 +1,152 @@ +#include "UI/ProjectTabLayout.h" +#include + +// projectTabOverflow is geometry: made-up ids and widths. +namespace { +const QUuid a = QUuid::createUuid(), b = QUuid::createUuid(), c = QUuid::createUuid(), d = QUuid::createUuid(), e = QUuid::createUuid(); + +// Every tab 100 wide. +std::map widths(const std::vector &ids) +{ + std::map result; + for (const QUuid &id : ids) + result.insert({id, 100}); + return result; +} + +const std::function pill = [](int) { return 80.0; }; + +std::vector ids(const ProjectTabOverflow &layout) +{ + std::vector result; + for (const ProjectTabSlot &slot : layout.visible) + result.push_back(slot.id); + return result; +} + +std::vector xs(const ProjectTabOverflow &layout) +{ + std::vector result; + for (const ProjectTabSlot &slot : layout.visible) + result.push_back(slot.x); + return result; +} +} + +class ProjectTabLayoutTests : public QObject { + Q_OBJECT +private slots: + void everythingFitsShowsAllTabsWithNoPill(); + void overflowHidesTheLeftmostTabsAndPillsThem(); + void selectedTabInTheHiddenSetIsPinnedRightOfThePill(); + void overflowLabelIsSingularForOneTab(); + void unmeasuredWidthShowsEverythingRatherThanGuessing(); + void aWiderSelectedTabStillFits(); + void thePillsWidthFollowsTheHiddenCount(); +}; + +void ProjectTabLayoutTests::everythingFitsShowsAllTabsWithNoPill() +{ + const std::vector order{a, b, c}; + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), c, 400, pill); + QVERIFY(result.hiddenIDs.empty()); + QVERIFY(!result.pill); + QVERIFY(ids(result) == order); + QVERIFY((xs(result) == std::vector{0, 106, 212})); + QCOMPARE(result.contentWidth(), 312.0); + // Exactly the room it needs still fits. + QVERIFY(!projectTabOverflow(order, widths(order), c, 312, pill).pill); + QVERIFY(projectTabOverflow(order, widths(order), c, 311.5, pill).pill); + QVERIFY(projectTabOverflow({}, {}, c, 400, pill) == ProjectTabOverflow()); + QCOMPARE(ProjectTabOverflow().contentWidth(), 0.0); +} + +void ProjectTabLayoutTests::overflowHidesTheLeftmostTabsAndPillsThem() +{ + const std::vector order{a, b, c, d, e}; + // The pill and two tabs: 292; a third needs 398. + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), e, 300, pill); + QVERIFY((result.hiddenIDs == std::vector{a, b, c})); + QVERIFY((ids(result) == std::vector{d, e})); + QVERIFY((result.pill.value() == ProjectTabPillSlot{0, 80})); + QCOMPARE(result.visible.front().x, 86.0); + QCOMPARE(result.visible.front().width, 100.0); + QCOMPARE(result.contentWidth(), 292.0); + // 292 exactly still holds two; a third at 398. + QCOMPARE(projectTabOverflow(order, widths(order), e, 292, pill).visible.size(), size_t(2)); + QCOMPARE(projectTabOverflow(order, widths(order), e, 398, pill).visible.size(), size_t(3)); + // Too narrow for even one: one tab stays. + QVERIFY((ids(projectTabOverflow(order, widths(order), e, 50, pill)) == std::vector{e})); +} + +void ProjectTabLayoutTests::selectedTabInTheHiddenSetIsPinnedRightOfThePill() +{ + const std::vector order{a, b, c, d, e}; + // a takes the first slot; d, bumped, hides in order. + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), a, 300, pill); + QVERIFY((ids(result) == std::vector{a, e})); + QVERIFY((result.hiddenIDs == std::vector{b, c, d})); + QCOMPARE(result.pill.value().width, 80.0); + QVERIFY((xs(result) == std::vector{86, 192})); +} + +void ProjectTabLayoutTests::overflowLabelIsSingularForOneTab() +{ + QCOMPARE(projectTabOverflowLabel(1), QString("1 more tab")); + QCOMPARE(projectTabOverflowLabel(2), QString("2 more tabs")); + QCOMPARE(projectTabOverflowLabel(11), QString("11 more tabs")); +} + +void ProjectTabLayoutTests::unmeasuredWidthShowsEverythingRatherThanGuessing() +{ + const std::vector order{a, b, c, d, e}; + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), a, 0, pill); + QVERIFY(result.hiddenIDs.empty()); + QVERIFY(!result.pill); + QCOMPARE(result.visible.size(), size_t(5)); +} + +// A long-titled hidden tab still leaves a row that fits. +void ProjectTabLayoutTests::aWiderSelectedTabStillFits() +{ + std::vector order; + std::map sizes; + for (int index = 0; index < 8; ++index) { + order.push_back(QUuid::createUuid()); + sizes.insert({order.back(), 80}); + } + sizes[order[0]] = 190; + const ProjectTabOverflow layout = projectTabOverflow(order, sizes, order[0], 500, [](int) { return 90.0; }); + QVERIFY2(layout.contentWidth() <= 500, qPrintable(QString::number(layout.contentWidth()))); + QCOMPARE(layout.visible.front().id, order[0]); + // Pill 90, 190, two of 80; three would make 522. + QVERIFY((ids(layout) == std::vector{order[0], order[6], order[7]})); + QVERIFY((layout.hiddenIDs == std::vector{order[1], order[2], order[3], order[4], order[5]})); + QCOMPARE(layout.contentWidth(), 90 + 6 + 190 + 6 + 80 + 6 + 80.0); + // Wide enough, it stands alone after the pill. + const std::vector five{a, b, c, d, e}; + std::map wide = widths(five); + wide[a] = 290; + const ProjectTabOverflow alone = projectTabOverflow(five, wide, a, 400, pill); + QVERIFY((ids(alone) == std::vector{a})); + QVERIFY((alone.hiddenIDs == std::vector{b, c, d, e})); +} + +// The pill's width is asked by its hidden count. +void ProjectTabLayoutTests::thePillsWidthFollowsTheHiddenCount() +{ + const std::vector order{a, b, c, d, e}; + std::vector asked; + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), e, 300, [&](int hidden) { + asked.push_back(hidden); + return 50.0 + hidden; + }); + // Three hidden fits (265); the last ask places it. + QVERIFY((asked == std::vector{0, 1, 2, 3, 3})); + QVERIFY((ids(result) == std::vector{d, e})); + QCOMPARE(result.pill.value().width, 53.0); + QCOMPARE(result.visible.front().x, 59.0); +} + +QTEST_GUILESS_MAIN(ProjectTabLayoutTests) +#include "ProjectTabLayoutTests.moc" diff --git a/tests/ProjectTabStripTests.cpp b/tests/ProjectTabStripTests.cpp index d2a5d6e..2345943 100644 --- a/tests/ProjectTabStripTests.cpp +++ b/tests/ProjectTabStripTests.cpp @@ -1,6 +1,5 @@ #include "UI/ProjectTabs.h" #include -#include #include #include @@ -26,7 +25,6 @@ class ProjectTabStripTests : public QObject { private slots: void eachTabIsAsWideAsItsTitle(); void aTitleMovesOnlyTheTabsAfterIt(); - void onlyANewFrontTabScrollsIntoView(); }; void ProjectTabStripTests::eachTabIsAsWideAsItsTitle() @@ -103,44 +101,5 @@ void ProjectTabStripTests::aTitleMovesOnlyTheTabsAfterIt() QCOMPARE(buttons[1]->x() - (buttons[0]->x() + buttons[0]->width()), 6); } -void ProjectTabStripTests::onlyANewFrontTabScrollsIntoView() -{ - ProjectWorkspace workspace; - for (int tab = 0; tab < 11; ++tab) - workspace.newCanvas(); - ProjectTabStrip strip(workspace); - strip.resize(300, 34); - strip.show(); - QVERIFY(QTest::qWaitForWindowExposed(&strip)); - QScrollBar &bar = *strip.horizontalScrollBar(); - // Scrolled back, another tab's change keeps the offset. - QTRY_VERIFY(bar.value() == bar.maximum() && bar.maximum() > 0); - // Scrolled back by hand, another tab's change keeps the offset. - bar.setValue(40); - workspace.tabs()[3]->session.setProjectPath(QStringLiteral("/p/Renamed.comp")); - QCoreApplication::processEvents(); - QCOMPARE(bar.value(), 40); - // The front tab turns unsaved: a dot, the offset kept. - workspace.current().session.createDocument(8, 8); - workspace.current().session.history.markSaved(); - QVERIFY(!workspace.current().session.isModified()); - workspace.current().session.addBlankLayer(); - QVERIFY(workspace.current().session.isModified()); - QCoreApplication::processEvents(); - QVERIFY(select(*strip.buttons().last()).text().startsWith(QStringLiteral("●"))); - QCOMPARE(bar.value(), 40); - // A tab closed behind is no new front tab either. - const QUuid front = workspace.selectedID(); - workspace.removeTab(workspace.tabs()[5]->id); - QCOMPARE(workspace.selectedID(), front); - QCoreApplication::processEvents(); - QCOMPARE(bar.value(), 40); - // Choosing another tab brings it in, as little as needed. - workspace.select(workspace.tabs()[0]->id); - QTRY_COMPARE(bar.value(), 0); - workspace.select(workspace.tabs()[1]->id); - QCOMPARE(bar.value(), 0); -} - QTEST_MAIN(ProjectTabStripTests) #include "ProjectTabStripTests.moc" diff --git a/tests/ProjectWorkspaceTests.cpp b/tests/ProjectWorkspaceTests.cpp index 393529e..87f26ab 100644 --- a/tests/ProjectWorkspaceTests.cpp +++ b/tests/ProjectWorkspaceTests.cpp @@ -42,6 +42,7 @@ private slots: void everyChangeIsAnnouncedAndRefusalsAreSilent(); void aDropThatIsNoLayerIdIsIgnored(); void anOpenFilterHoldsTheSwitch(); + void moveTabReordersWithoutTouchingSelectionOrDocuments(); }; void ProjectWorkspaceTests::newCanvasOpensAnEmptyTabWithoutAModal() @@ -358,5 +359,36 @@ void ProjectWorkspaceTests::anOpenFilterHoldsTheSwitch() QVERIFY(workspace.canSwitch()); } +void ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments() +{ + ProjectWorkspace workspace; + const QUuid a = workspace.current().id, b = workspace.addTab(false).id, c = workspace.addTab(false).id; + const auto order = [&] { + std::vector ids; + for (const std::shared_ptr &tab : workspace.tabs()) + ids.push_back(tab->id); + return ids; + }; + QSignalSpy changed(&workspace, &ProjectWorkspace::changed); + workspace.moveTab(c, 0); + QVERIFY((order() == std::vector{c, a, b})); + workspace.moveTab(a, 2); + QVERIFY((order() == std::vector{c, b, a})); + QCOMPARE(changed.size(), 2); + // Out of range clamps to the ends. + workspace.moveTab(c, 99); + QVERIFY((order() == std::vector{b, a, c})); + workspace.moveTab(c, -4); + QVERIFY((order() == std::vector{c, b, a})); + QCOMPARE(changed.size(), 4); + // Where it is, or no tab: nothing, silently. + workspace.moveTab(a, 2); + workspace.moveTab(QUuid::createUuid(), 0); + QVERIFY((order() == std::vector{c, b, a})); + QCOMPARE(changed.size(), 4); + // Chrome: the selection stays. + QCOMPARE(workspace.selectedID(), c); +} + QTEST_MAIN(ProjectWorkspaceTests) #include "ProjectWorkspaceTests.moc" diff --git a/tests/ProjectWorkspaceViewTests.cpp b/tests/ProjectWorkspaceViewTests.cpp index a62448a..696cd78 100644 --- a/tests/ProjectWorkspaceViewTests.cpp +++ b/tests/ProjectWorkspaceViewTests.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include // The window: tabs, the toolbar, closing and quitting. @@ -203,29 +204,27 @@ void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState() late.session.setProjectPath(QString("/x/Late.comp")); workspace.select(workspace.tabs()[0]->id); QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2", "Late"})); - // More tabs than fit: the front one scrolls into view. + // More tabs than fit: the rest gather under the pill. QVERIFY(QTest::qWaitForWindowExposed(&window)); - for (int index = 0; index < 8; ++index) + for (int index = 0; index < 12; ++index) workspace.addTab(false).session.setProjectPath(QString("/x/Wide project number %1.comp").arg(index)); const ProjectTabButton *last = strip.buttons().last(); const auto wholeInView = [&](const ProjectTabButton *button) { - return strip.viewport()->rect().contains(QRect(button->mapTo(strip.viewport(), QPoint(0, 0)), button->size())); + return button->isVisible() && strip.rect().contains(button->geometry()); }; - // In view once laid out, as Swift posts its reveal. QVERIFY(last->tab->id == workspace.selectedID()); - QTRY_VERIFY(wholeInView(last)); - QVERIFY(strip.widget()->width() > strip.viewport()->width()); - QVERIFY(last->findChild("selectTab")->width() >= 35 && strip.horizontalScrollBar()->maximum() > 0); - // Since 1.2.4 a growing title keeps the offset. - const int offset = strip.horizontalScrollBar()->value(); + QVERIFY(wholeInView(last)); + QVERIFY(strip.pill()->isVisible() && !strip.buttons().first()->isVisible()); + // The strip ends before the zoom buttons. + const QWidget &fitButton = *window.findChild("toolbar")->widgetForAction(&action(window, "fitCanvas")); + QVERIFY(strip.mapTo(&window, QPoint(strip.width(), 0)).x() <= fitButton.mapTo(&window, QPoint()).x()); + // A growing title keeps the front tab whole. workspace.tabs().back()->session.setProjectPath(QString("/x/") + QString(60, QLatin1Char('W')) + ".comp"); - QCoreApplication::processEvents(); - QCoreApplication::processEvents(); QVERIFY(last->width() > 180); - QCOMPARE(strip.horizontalScrollBar()->value(), offset); + QVERIFY(wholeInView(last)); workspace.select(workspace.tabs()[0]->id); const ProjectTabButton *head = strip.buttons().first(); - QTRY_VERIFY(wholeInView(head)); + QVERIFY(wholeInView(head)); } void ProjectWorkspaceViewTests::tabButtonsSelectAndClose() diff --git a/tests/Tests.cmake b/tests/Tests.cmake index ea6e3a5..a62c962 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -198,6 +198,8 @@ compositor_test(ProjectWorkspaceSignalTests) compositor_test(ProjectWorkspaceTests) compositor_test(ProjectWorkspaceViewTests) compositor_test(ProjectTabStripTests) +compositor_test(ProjectTabDragTests) +compositor_test(ProjectTabLayoutTests) compositor_test(KeyboardZoomTests) # It starts the real app beside itself. add_dependencies(ProjectWorkspaceViewTests omaphoto) From 6ea248367dfbb628b891741a24c5510e6db2c029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 16:22:27 -0400 Subject: [PATCH 03/17] =?UTF-8?q?Layer=20=E2=80=BA=20Ungroup=20Layers=20(C?= =?UTF-8?q?trl+Shift+G),=20in=20a=20folder's=20menu=20too;=20Alt=20over=20?= =?UTF-8?q?a=20mask=20shows=20the=20eye=20pointer=20(13.15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Document/LayerGroups+Session.h | 3 + src/Document/LayerGroups.cpp | 38 ++++++++ src/UI/CompositorMenus.cpp | 2 + src/UI/KeyboardShortcuts.cpp | 2 +- src/UI/NativeLayerList+Drag.cpp | 36 +++++++- src/UI/NativeLayerList+Menu.cpp | 4 + src/UI/NativeLayerList.h | 2 + tests/CompositorMenusTests.cpp | 15 +++- tests/GroupSessionTests.cpp | 135 +++++++++++++++++++++++++++++ tests/KeyboardShortcutsTests.cpp | 4 +- tests/LayerContextMenuTests.cpp | 26 ++++++ tests/LayerDragGestureTests.cpp | 16 +++- 12 files changed, 271 insertions(+), 12 deletions(-) diff --git a/src/Document/LayerGroups+Session.h b/src/Document/LayerGroups+Session.h index d8847fe..5c7c821 100644 --- a/src/Document/LayerGroups+Session.h +++ b/src/Document/LayerGroups+Session.h @@ -7,6 +7,9 @@ public: void selectLayers(const QSet &ids, std::optional primary); void extendSelection(QUuid id); void groupSelectedLayers(); + // The active layer is a folder: something to unwrap. + bool canUngroupLayers() const; + void ungroupLayers(); std::vector layerRows() const; bool canPlaceLayer(QUuid id, std::optional parent) const; bool placeLayer(QUuid id, std::optional parent, std::optional above = std::nullopt, bool atBottom = false); diff --git a/src/Document/LayerGroups.cpp b/src/Document/LayerGroups.cpp index a3726bf..bba445a 100644 --- a/src/Document/LayerGroups.cpp +++ b/src/Document/LayerGroups.cpp @@ -317,6 +317,44 @@ void EditorSession::groupSelectedLayers() endEdit(); } +bool EditorSession::canUngroupLayers() const +{ + const std::optional active = activeLayer(); + return canEditLayers() && active && active->isGroup; +} + +// The folder's children take its place, in order. +void EditorSession::ungroupLayers() +{ + if (!canUngroupLayers()) + return; + const ImageLayer group = activeLayer().value(); + std::vector children; + for (const ImageLayer &layer : m_document->layers) { + if (layer.parentID == group.id) { + children.push_back(layer); + children.back().parentID = group.parentID; + } + } + std::vector layers; + QSet childIDs; + for (const ImageLayer &child : children) + childIDs.insert(child.id); + for (const ImageLayer &layer : m_document->layers) { + if (layer.id == group.id) + layers.insert(layers.end(), children.begin(), children.end()); + else if (!childIDs.contains(layer.id)) + layers.push_back(layer); + } + releaseDetachedClipping(layers); + finishOpacityEdit(); + beginEdit(QStringLiteral("Ungroup Layers")); + m_document->layers = layers; + selectLayers(childIDs, children.empty() ? std::nullopt : std::optional(children.front().id)); + m_collapsedGroupIDs.remove(group.id); + endEdit(); +} + std::vector EditorSession::layerRows() const { std::vector records; diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index 26f002d..2800289 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -255,6 +255,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi }); layer->addSeparator(); add(layer, QStringLiteral("groupLayers"), QStringLiteral("Group Selected Layers"), QKeySequence(Qt::CTRL | Qt::Key_G), [this] { session().groupSelectedLayers(); }); + add(layer, QStringLiteral("ungroupLayers"), QStringLiteral("Ungroup Layers"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_G), [this] { session().ungroupLayers(); }); add(layer, QStringLiteral("moveOutOfFolder"), QStringLiteral("Move Out of Folder"), QKeySequence(), [this] { session().moveActiveLayerOutOfGroup(); }); add(layer, QStringLiteral("newBlankLayer"), QStringLiteral("New Blank Layer"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_N), [this] { session().addBlankLayer(); }); add(layer, QStringLiteral("renameLayer"), QStringLiteral("Rename Layer…"), QKeySequence(), [this] { session().setRenamingLayerID(session().activeLayerID()); }); @@ -407,6 +408,7 @@ void CompositorMenus::synchronize() action(QStringLiteral("clippingMask"))->setText(active && active->maskSourceID ? QStringLiteral("Release Clipping Mask") : QStringLiteral("Create Clipping Mask")); action(QStringLiteral("clippingMask"))->setEnabled(s.activeLayerID() && s.canToggleClippingMask(*s.activeLayerID())); action(QStringLiteral("groupLayers"))->setEnabled(s.canEditLayers()); + action(QStringLiteral("ungroupLayers"))->setEnabled(s.canUngroupLayers()); action(QStringLiteral("moveOutOfFolder"))->setEnabled(s.canEditLayers() && active && active->parentID); action(QStringLiteral("newBlankLayer"))->setEnabled(s.canEditLayers()); action(QStringLiteral("renameLayer"))->setEnabled(s.canEditLayers() && active); diff --git a/src/UI/KeyboardShortcuts.cpp b/src/UI/KeyboardShortcuts.cpp index ffa2db5..12121e4 100644 --- a/src/UI/KeyboardShortcuts.cpp +++ b/src/UI/KeyboardShortcuts.cpp @@ -134,7 +134,7 @@ const std::vector &ShortcutDefinition::all() entry("Levels", "l", 1, true), entry("Hue/Saturation", "u", 1, true), entry("Invert Pixels / Mask", "i", 1, true), entry("Canvas Size", "c", 3, true), entry("Image Size", "i", 3, true), entry("Transform Layer / Selection", "t", 1, true), entry("Duplicate / Layer via Copy", "j", 1, true), entry("Toggle Clipping Mask", "g", 3, true), - entry("Group Layers", "g", 1, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true), + entry("Group Layers", "g", 1, true), entry("Ungroup Layers", "g", 9, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true), entry("Move Layer Down", "[", 1, true), entry("Merge Layers", "e", 1, true), entry("Show Grid", "'", 1, true), entry("Show Guides", ";", 1, true), entry("Show Rulers", "r", 1, true), entry("Snap", ";", 9, true), entry("Lock Guides", ";", 3, true)}; const std::vector> tools{ diff --git a/src/UI/NativeLayerList+Drag.cpp b/src/UI/NativeLayerList+Drag.cpp index c8a3005..b277ee8 100644 --- a/src/UI/NativeLayerList+Drag.cpp +++ b/src/UI/NativeLayerList+Drag.cpp @@ -13,6 +13,7 @@ #include #include #include +#include const QString NativeLayerList::maskType = QStringLiteral("com.compositor.layer-mask"); const QString NativeLayerList::sourceType = QStringLiteral("com.compositor.layer-list"); @@ -299,6 +300,37 @@ QCursor NativeLayerList::clippingCursor(bool releasing, double ratio) return QCursor(pixmap, 3, 3); } +// Swift's eye.fill: an almond, a ring round the pupil. +QCursor NativeLayerList::showMaskCursor(double ratio) +{ + const QCursor base = CanvasView::duplicateCursor(ratio); + const QPointF hotSpot = base.hotSpot(); + const QRectF eye(hotSpot + QPointF(15, 18), QSizeF(7.5, 5.5)); + const QSizeF baseSize = base.pixmap().deviceIndependentSize(); + const QSize size(int(std::ceil(std::max(baseSize.width(), eye.right() + 2))), int(std::ceil(std::max(baseSize.height(), eye.bottom() + 2)))); + QPixmap pixmap(size * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + QPainterPath shape; + shape.setFillRule(Qt::OddEvenFill); + shape.moveTo(eye.left(), eye.center().y()); + shape.quadTo(eye.center().x(), eye.top() - eye.height() / 2, eye.right(), eye.center().y()); + shape.quadTo(eye.center().x(), eye.bottom() + eye.height() / 2, eye.left(), eye.center().y()); + shape.addEllipse(eye.center(), 1.9, 1.9); + shape.addEllipse(eye.center(), 1.1, 1.1); + // The eye first, haloed, so the arrows sit in front. + for (int step = 0; step < 16; ++step) { + const double angle = step * M_PI / 8; + painter.fillPath(shape.translated(std::cos(angle), std::sin(angle)), Qt::white); + } + painter.fillPath(shape, Qt::black); + painter.drawPixmap(QPointF(0, 0), base.pixmap()); + painter.end(); + return QCursor(pixmap, int(hotSpot.x()), int(hotSpot.y())); +} + QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const { const int row = rowAt(listPoint); @@ -317,9 +349,9 @@ QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modif return QCursor(Qt::ArrowCursor); } const bool editable = m_session.canEditLayers(); - // A mask copies, the row duplicates, the strip clips. + // A mask shows alone, the row duplicates, the strip clips. if (cell.maskThumbnail().isVisible() && cell.maskThumbnail().geometry().contains(inCell)) - return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor); + return showMaskCursor(ratio); if (!isClippingZone(cell, inCell)) return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor); if (!m_session.canToggleClippingMask(layer.id)) diff --git a/src/UI/NativeLayerList+Menu.cpp b/src/UI/NativeLayerList+Menu.cpp index 6c7bdd0..7aa645c 100644 --- a/src/UI/NativeLayerList+Menu.cpp +++ b/src/UI/NativeLayerList+Menu.cpp @@ -1,5 +1,6 @@ #include "UI/NativeLayerList.h" #include +#include // Swift's table-level context menu (1.2.5): entries and validation. std::unique_ptr NativeLayerList::contextMenu(QUuid id) @@ -40,6 +41,9 @@ std::unique_ptr NativeLayerList::contextMenu(QUuid id) entry(*menu, "groupLayers", QStringLiteral("Group Selected Layers"), session.canEditLayers() && session.document() && session.document()->layers.size() < 10'000 && !session.selectedLayerIDs().isEmpty(), [&session] { session.groupSelectedLayers(); }); + // A folder right-clicked can be ungrouped. + if (std::ranges::any_of(session.document()->layers, [id](const ImageLayer &layer) { return layer.id == id && layer.isGroup; })) + entry(*menu, "ungroupLayers", QStringLiteral("Ungroup Layers"), session.canUngroupLayers(), [&session] { session.ungroupLayers(); }); entry(*menu, "moveOut", QStringLiteral("Move Out of Folder"), session.canEditLayers() && active && active->parentID, [&session] { session.moveActiveLayerOutOfGroup(); }); entry(*menu, "mergeLayers", session.mergeTitle(), session.canMergeLayers(), [&session] { session.mergeLayers(); }); diff --git a/src/UI/NativeLayerList.h b/src/UI/NativeLayerList.h index 33028f8..2212cbe 100644 --- a/src/UI/NativeLayerList.h +++ b/src/UI/NativeLayerList.h @@ -242,6 +242,8 @@ public: // The cursor Alt asks for here: clip, duplicate, arrow. QCursor cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const; static QCursor clippingCursor(bool releasing, double ratio); + // Alt over a mask: the duplicate pointer, an eye behind. + static QCursor showMaskCursor(double ratio); protected: bool event(QEvent *event) override; diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index 9e473de..8f92934 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -32,7 +32,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() {"closeProject", "Ctrl+W"}, {"undo", "Ctrl+Z"}, {"redo", "Ctrl+Shift+Z"}, {"fit", "Ctrl+0"}, {"actualPixels", "Ctrl+1"}, {"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"snap", ""}, {"showGrid", "Ctrl+'"}, {"showGuides", "Ctrl+;"}, {"gridSettings", ""}, {"showRulers", "Ctrl+R"}, {"snapEnabled", "Ctrl+Shift+;"}, {"snapToGuides", ""}, {"snapToGrid", ""}, {"snapToLayers", ""}, {"snapToDocumentBounds", ""}, {"lockGuides", "Ctrl+Alt+;"}, {"clearGuides", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"}, {"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"keyboardShortcuts", ""}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"}, - {"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["}, + {"ungroupLayers", "Ctrl+Shift+G"}, {"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["}, {"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""}, {"selectAll", "Ctrl+A"}, {"deselect", "Ctrl+D"}, {"inverse", "Ctrl+Shift+I"}, {"layerPixels", ""}, {"subject", "Ctrl+Alt+A"}, {"colorRange", ""}, {"maskBlackAreas", ""}, {"expandSelection", ""}, {"contractSelection", ""}, {"featherSelection", ""}, {"curves", "Ctrl+M"}, {"levels", "Ctrl+L"}, {"hueSaturation", "Ctrl+U"}, {"blackWhite", ""}, {"colorBalance", ""}, {"exposure", ""}, {"gradientMap", ""}, {"grain", ""}, {"invert", "Ctrl+I"}, @@ -72,7 +72,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() for (QAction *entry : menus[5]->actions()) filters << entry->text(); QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Dither…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"})); - QCOMPARE(menus[6]->actions().size(), 22); + QCOMPARE(menus[6]->actions().size(), 23); } void CompositorMenusTests::fileEntriesFollowTheControllersGate() @@ -255,7 +255,7 @@ void CompositorMenusTests::theMenuBarBorrowsFocusAndTheFieldKeepsUndo() void CompositorMenusTests::layerEntriesFollowTheActiveLayer() { Bar bar; - for (const char *name : {"clippingMask", "groupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) + for (const char *name : {"clippingMask", "groupLayers", "ungroupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) QVERIFY2(!bar.action(name).isEnabled(), name); bar.session().createDocument(8, 8); QVERIFY(bar.action("groupLayers").isEnabled() && bar.action("newBlankLayer").isEnabled()); @@ -297,9 +297,16 @@ void CompositorMenusTests::layerEntriesFollowTheActiveLayer() bar.action("moveOutOfFolder").trigger(); QCOMPARE(bar.session().activeLayer().value().parentID, std::nullopt); QVERIFY(!bar.action("moveOutOfFolder").isEnabled()); + QVERIFY(!bar.action("ungroupLayers").isEnabled()); + bar.session().selectLayers({lower}, lower); + const std::optional outer = bar.session().activeLayer().value().parentID; + bar.action("groupLayers").trigger(); + QVERIFY(bar.action("ungroupLayers").isEnabled() && bar.action("ungroupLayers").text() == "Ungroup Layers"); + bar.action("ungroupLayers").trigger(); + QVERIFY(outer && bar.session().activeLayer().value().parentID == outer && bar.session().history.undoName() == "Ungroup Layers"); // A busy project closes every layer entry. bar.session().setIsProjectBusy(true); - for (const char *name : {"clippingMask", "groupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) + for (const char *name : {"clippingMask", "groupLayers", "ungroupLayers", "moveOutOfFolder", "newBlankLayer", "renameLayer", "layerVisibility", "moveLayerUp", "moveLayerDown", "deleteLayer"}) QVERIFY2(!bar.action(name).isEnabled(), name); } diff --git a/tests/GroupSessionTests.cpp b/tests/GroupSessionTests.cpp index 91455ea..5009fc0 100644 --- a/tests/GroupSessionTests.cpp +++ b/tests/GroupSessionTests.cpp @@ -3,6 +3,7 @@ #include "IO/ImageExporter.h" #include "IO/ImageResizer.h" #include +#include #include // The session cases of Swift's GroupTests. @@ -42,6 +43,10 @@ private slots: void hiddenParentOverridesChildrenAndExportOrderFollowsGroups(); void groupsRoundTripAndSurviveImageAndCanvasResize(); void rowsOfASessionWithoutADocument(); + void ungroupLayersRestoresChildrenAtTheFoldersSpotAndUndoes(); + void ungroupPreservesClippingBetweenTwoOfAFoldersOwnChildren(); + void ungroupingReleasesClippingThatNoLongerMakesSense(); + void ungroupingANestedOrEmptyFolder(); }; void GroupSessionTests::nestedGroupsMoveOutCollapseAndDeleteUndo() @@ -249,5 +254,135 @@ void GroupSessionTests::rowsOfASessionWithoutADocument() QVERIFY(EditorSession().layerRows().empty()); } +void GroupSessionTests::ungroupLayersRestoresChildrenAtTheFoldersSpotAndUndoes() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid below = session.activeLayerID().value(); + QVERIFY(!session.canUngroupLayers()); + session.addGroup(); + const QUuid group = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid childA = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid childB = session.activeLayerID().value(); + session.selectLayer(std::nullopt); + session.addBlankLayer(); + const QUuid above = session.activeLayerID().value(); + QCOMPARE(parentOf(session, childA), std::optional(group)); + session.selectLayer(group); + session.toggleGroupExpansion(group); + QVERIFY(session.collapsedGroupIDs().contains(group)); + QVERIFY(session.canUngroupLayers()); + const int undoCount = session.history.undoCount(); + const qsizetype count = qsizetype(session.document().value().layers.size()); + QSignalSpy changed(&session, &EditorSession::changed); + session.ungroupLayers(); + QVERIFY(!changed.isEmpty()); + std::vector order; + for (const ImageLayer &layer : session.document().value().layers) + order.push_back(layer.id); + QVERIFY(std::ranges::find(order, group) == order.end()); + QCOMPARE(parentOf(session, childA), std::nullopt); + QCOMPARE(parentOf(session, childB), std::nullopt); + // Spliced in where the folder sat. + const auto at = [&](QUuid id) { return std::ranges::find(order, id) - order.begin(); }; + QCOMPARE(at(childA), at(below) + 1); + QCOMPARE(at(childB), at(childA) + 1); + QCOMPARE(at(above), at(childB) + 1); + QCOMPARE(qsizetype(order.size()), count - 1); + QCOMPARE(session.selectedLayerIDs(), (QSet{childA, childB})); + QCOMPARE(session.activeLayerID(), std::optional(childA)); + QVERIFY(!session.collapsedGroupIDs().contains(group)); + QCOMPARE(session.history.undoCount(), undoCount + 1); + QCOMPARE(session.history.undoName(), QString("Ungroup Layers")); + session.undo(); + QCOMPARE(parentOf(session, childA), std::optional(group)); + session.redo(); + QCOMPARE(qsizetype(session.document().value().layers.size()), count - 1); + // A plain layer has nothing to unwrap; nothing changes. + changed.clear(); + session.selectLayer(childA); + changed.clear(); + session.ungroupLayers(); + QVERIFY(changed.isEmpty()); + QCOMPARE(qsizetype(session.document().value().layers.size()), count - 1); +} + +void GroupSessionTests::ungroupPreservesClippingBetweenTwoOfAFoldersOwnChildren() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid base = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid clipped = session.activeLayerID().value(); + QVERIFY(session.linkMask(base, clipped)); + session.selectLayers({base, clipped}, base); + session.groupSelectedLayers(); + session.ungroupLayers(); + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == clipped) + QCOMPARE(layer.maskSourceID, std::optional(base)); + } +} + +void GroupSessionTests::ungroupingReleasesClippingThatNoLongerMakesSense() +{ + EditorSession session; + session.createDocument(100, 100); + session.addBlankLayer(); + const QUuid outsideBase = session.activeLayerID().value(); + session.addBlankLayer(); + session.addBlankLayer(); + const QUuid childSource = session.activeLayerID().value(); + // linkMask allows a clip across a folder's edge. + QVERIFY(session.linkMask(outsideBase, childSource)); + session.selectLayer(childSource); + session.groupSelectedLayers(); + session.ungroupLayers(); + for (const ImageLayer &layer : session.document().value().layers) { + if (layer.id == childSource) + QCOMPARE(layer.maskSourceID, std::nullopt); + } +} + +void GroupSessionTests::ungroupingANestedOrEmptyFolder() +{ + EditorSession session; + session.createDocument(100, 100); + session.addGroup(); + const QUuid outer = session.activeLayerID().value(); + session.addGroup(); + const QUuid inner = session.activeLayerID().value(); + session.addBlankLayer(); + const QUuid child = session.activeLayerID().value(); + // Children join the folder's parent; a drag ends first. + session.selectLayer(inner); + session.beginOpacityEdit(); + session.setLayerOpacity(0.5); + QVERIFY(!session.canUndo()); + session.ungroupLayers(); + QVERIFY(session.canUndo()); + QCOMPARE(session.history.undoName(), QString("Ungroup Layers")); + QCOMPARE(parentOf(session, child), std::optional(outer)); + QCOMPARE(session.selectedLayerIDs(), QSet{child}); + // An empty folder just goes, nothing left chosen. + session.addGroup(); + const QUuid empty = session.activeLayerID().value(); + const qsizetype count = qsizetype(session.document().value().layers.size()); + session.ungroupLayers(); + QCOMPARE(qsizetype(session.document().value().layers.size()), count - 1); + QVERIFY(std::ranges::none_of(session.document().value().layers, [&](const ImageLayer &layer) { return layer.id == empty; })); + QVERIFY(session.selectedLayerIDs().isEmpty() && !session.activeLayerID()); + // Busy, it rests. + session.selectLayer(outer); + session.setIsProjectBusy(true); + QVERIFY(!session.canUngroupLayers()); + session.setIsProjectBusy(false); + QVERIFY(session.canUngroupLayers()); +} + QTEST_GUILESS_MAIN(GroupSessionTests) #include "GroupSessionTests.moc" diff --git a/tests/KeyboardShortcutsTests.cpp b/tests/KeyboardShortcutsTests.cpp index bd2370d..bdc495a 100644 --- a/tests/KeyboardShortcutsTests.cpp +++ b/tests/KeyboardShortcutsTests.cpp @@ -92,8 +92,8 @@ void KeyboardShortcutsTests::chordsReadKeysAsSwiftDoes() void KeyboardShortcutsTests::theListIsSwiftsWithoutHide() { const std::vector &all = ShortcutDefinition::all(); - // Swift's 112 since 1.1.7, less Hide Compositor. - QCOMPARE(int(all.size()), 111); + // Swift's 113 with Ungroup Layers, less Hide Compositor. + QCOMPARE(int(all.size()), 112); QSet ids; for (const ShortcutDefinition &definition : all) ids.insert(definition.id()); diff --git a/tests/LayerContextMenuTests.cpp b/tests/LayerContextMenuTests.cpp index 4866771..f234d8e 100644 --- a/tests/LayerContextMenuTests.cpp +++ b/tests/LayerContextMenuTests.cpp @@ -22,6 +22,7 @@ private slots: void entriesFollowTheSelectionAndTheGates(); void thumbnailsAndEffectsChooseWhatTheMenuTargets(); void testContextMenuDuplicateFolderPreservesHierarchy(); + void aFoldersMenuOffersUngroup(); }; void LayerContextMenuTests::testContextMenuContainsCoreLayerActions() @@ -312,5 +313,30 @@ void LayerContextMenuTests::testContextMenuDuplicateFolderPreservesHierarchy() QCOMPARE(session->document().value().layers.size(), 2 * children + 2); } +void LayerContextMenuTests::aFoldersMenuOffersUngroup() +{ + const auto session = threeLayers(); + const QUuid lower = session->document().value().layers[0].id; + session->selectLayers({lower}, lower); + session->groupSelectedLayers(); + Shown shown(*session); + // The folder sits lowest; the top row is none. + session->selectLayer(std::nullopt); + QVERIFY(!titles(*shown.menu(0)).contains("Ungroup Layers")); + const int folder = int(shown.list.cells().size()) - 2; + QVERIFY(shown.row(folder).layerID() != lower); + std::unique_ptr menu = shown.menu(folder); + const QStringList entries = titles(*menu); + QCOMPARE(entries.indexOf("Ungroup Layers"), entries.indexOf("Group Selected Layers") + 1); + QVERIFY(item(*menu, "ungroupLayers").isEnabled()); + session->setIsProjectBusy(true); + QVERIFY(!item(*shown.menu(folder), "ungroupLayers").isEnabled()); + session->setIsProjectBusy(false); + item(*shown.menu(folder), "ungroupLayers").trigger(); + QCOMPARE(session->history.undoName(), QString("Ungroup Layers")); + QCOMPARE(session->document().value().layers.size(), size_t(3)); + QCOMPARE(session->selectedLayerIDs(), QSet{lower}); +} + QTEST_MAIN(LayerContextMenuTests) #include "LayerContextMenuTests.moc" diff --git a/tests/LayerDragGestureTests.cpp b/tests/LayerDragGestureTests.cpp index 990cec9..c905f0a 100644 --- a/tests/LayerDragGestureTests.cpp +++ b/tests/LayerDragGestureTests.cpp @@ -74,15 +74,25 @@ void LayerDragGestureTests::altShowsTheClippingAndDuplicateCursors() QCOMPARE(image(shown.list.cursorFor(folderBody, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); const QPoint folderStrip = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, LayerCell::rowHeight - 4)); QCOMPARE(image(shown.list.cursorFor(folderStrip, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); - // A mask thumbnail copies, or loads with Ctrl; busy, nothing. + // Alt over a mask: an eye behind the pointer. session->selectLayer(shown.id(0)); session->addLayerMask(); LayerCell &top = *shown.list.cells().at(0); const QPoint mask = top.mapTo(&shown.list, top.maskThumbnail().geometry().center()); - QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio))); + QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(NativeLayerList::showMaskCursor(ratio))); QCOMPARE(image(shown.list.cursorFor(mask, Qt::ControlModifier)), image(CanvasView::loadSelectionCursor(ratio))); + // The pointer's hot spot; the eye sits below right. + const QCursor eye = NativeLayerList::showMaskCursor(1); + QCOMPARE(eye.hotSpot(), CanvasView::duplicateCursor(1).hotSpot()); + QCOMPARE(eye.pixmap().size(), QSize(29, 32)); + const QImage drawn = image(eye); + QCOMPARE(drawn.pixelColor(22, 23), QColor(Qt::black)); + QVERIFY(drawn.pixelColor(21, 23).lightness() > 150); + QCOMPARE(drawn.pixelColor(25, 23).alpha(), 255); + QCOMPARE(drawn.copy(0, 0, 18, 18), image(CanvasView::duplicateCursor(1)).copy(0, 0, 18, 18)); + QCOMPARE(image(CanvasView::duplicateCursor(1)).pixelColor(23, 23).alpha(), 0); session->setIsImporting(true); - QCOMPARE(shown.list.cursorFor(mask, Qt::AltModifier).shape(), Qt::ArrowCursor); + QCOMPARE(image(shown.list.cursorFor(mask, Qt::AltModifier)), image(NativeLayerList::showMaskCursor(ratio))); QCOMPARE(shown.list.cursorFor(mask, Qt::ControlModifier).shape(), Qt::ArrowCursor); QCOMPARE(shown.list.cursorFor(body, Qt::AltModifier).shape(), Qt::ArrowCursor); session->setIsImporting(false); From fe3198777ddef0bee0df8e30a2c43e12e6ff979f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 16:30:30 -0400 Subject: [PATCH 04/17] New Canvas's dots are filled, never outlined: pinned (13.7) --- tests/NewCanvasSheetTests.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/NewCanvasSheetTests.cpp b/tests/NewCanvasSheetTests.cpp index 8233c4f..b7cd090 100644 --- a/tests/NewCanvasSheetTests.cpp +++ b/tests/NewCanvasSheetTests.cpp @@ -325,6 +325,12 @@ void NewCanvasSheetTests::theMoreButtonDrawsThreeDotsAndOpensTheMenu() QVERIFY2(std::abs(qGray(image.pixel(26, y)) - qGray(ink.rgb())) < std::abs(qGray(image.pixel(26, y)) - qGray(ground.rgb())), qPrintable(QString::number(y))); for (const QPoint clear : {QPoint(26, 11), QPoint(26, 16), QPoint(26, 5), QPoint(26, 23), QPoint(22, 14), QPoint(14, 14)}) QCOMPARE(image.pixelColor(clear), ground); + // Filled, never outlined: a red ink stays red. + QPalette red = fixture.presets.palette(); + red.setColor(QPalette::WindowText, Qt::red); + fixture.presets.setPalette(red); + const QColor centre = fixture.presets.grab().toImage().pixelColor(26, 14); + QVERIFY2(centre.red() > 200 && centre.red() - centre.green() > 60, qPrintable(centre.name())); bool shown = false; QTimer::singleShot(0, fixture.presets.menu(), [&] { shown = fixture.presets.menu()->isVisible(); From 01bb5790689d2617a263ff0e8a16b66203e62d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 16:43:33 -0400 Subject: [PATCH 05/17] Tab drags: a right move by one, a lost release, a close mid-drag pinned (13.14) --- tests/ProjectTabDragTests.cpp | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/ProjectTabDragTests.cpp b/tests/ProjectTabDragTests.cpp index da95801..32ba357 100644 --- a/tests/ProjectTabDragTests.cpp +++ b/tests/ProjectTabDragTests.cpp @@ -74,6 +74,7 @@ private slots: void aSmallOrUpwardMoveIsAClick(); void aBusyProjectHoldsTheOrder(); void aTabClosedMidDragEndsIt(); + void aLostReleaseGivesWayToTheNextDrag(); void tabsThatDoNotFitGatherInAMenu(); void theMenuSwitchesAndPinsTheChosenTab(); void aBusyProjectOpensNoMenu(); @@ -132,11 +133,16 @@ void ProjectTabDragTests::aDraggedTabFollowsThePointerAndLandsInTheGap() fixture.move(back - QPoint(third.width() / 2 + 10, 0)); fixture.release("Untitled", back - QPoint(third.width() / 2 + 10, 0)); QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled", "Untitled 3"})); + // The first, dragged one place right. + const QPoint right = fixture.press("Untitled 2"); + fixture.move(right + QPoint(first.width() / 2 + 10, 0)); + fixture.release("Untitled 2", right + QPoint(first.width() / 2 + 10, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"})); // Let go where it began: the order stays. const QPoint still = fixture.press("Untitled 3"); fixture.move(still + QPoint(5, 0)); fixture.release("Untitled 3", still + QPoint(5, 0)); - QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled", "Untitled 3"})); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"})); QTRY_VERIFY(settled(fixture.strip)); QCOMPARE(third.x(), second.width() + first.width() + 12); } @@ -181,11 +187,13 @@ void ProjectTabDragTests::aTabClosedMidDragEndsIt() { Strip fixture; const QPoint start = fixture.press("Untitled 2"); - fixture.move(start + QPoint(5000, 0)); - QVERIFY(button(fixture.strip, "Untitled 2").x() > 100); + fixture.move(start - QPoint(5000, 0)); + QTRY_VERIFY(settled(fixture.strip)); + QVERIFY(button(fixture.strip, "Untitled").x() > 0); fixture.workspace.removeTab(fixture.workspace.tabs()[1]->id); QCOMPARE(fixture.strip.buttons().size(), 2); QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(button(fixture.strip, "Untitled").x(), 0); QCOMPARE(button(fixture.strip, "Untitled 3").x(), button(fixture.strip, "Untitled").width() + 6); // Another drag works at once. const QPoint next = fixture.press("Untitled 3"); @@ -194,6 +202,20 @@ void ProjectTabDragTests::aTabClosedMidDragEndsIt() QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 3", "Untitled"})); } +void ProjectTabDragTests::aLostReleaseGivesWayToTheNextDrag() +{ + Strip fixture; + const QPoint first = fixture.press("Untitled"); + fixture.move(first + QPoint(5000, 0)); + // Its release never comes; another tab is dragged. + const QPoint next = fixture.press("Untitled 3"); + fixture.move(next - QPoint(5000, 0)); + fixture.release("Untitled 3", next - QPoint(5000, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 3", "Untitled", "Untitled 2"})); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(button(fixture.strip, "Untitled 3").x(), 0); +} + void ProjectTabDragTests::tabsThatDoNotFitGatherInAMenu() { Strip fixture(12, 400); From 5be68a4f96d0bda979ec87bbe082ab89f8c2e94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 17:06:31 -0400 Subject: [PATCH 06/17] Tab drags: the three-point threshold, an unheld move and stacking pinned; no minimum hint (13.14) --- src/UI/ProjectTabs+Strip.cpp | 5 ----- src/UI/ProjectTabs.h | 1 - tests/ProjectTabDragTests.cpp | 11 ++++++++++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/UI/ProjectTabs+Strip.cpp b/src/UI/ProjectTabs+Strip.cpp index bc1c05f..bd5c985 100644 --- a/src/UI/ProjectTabs+Strip.cpp +++ b/src/UI/ProjectTabs+Strip.cpp @@ -24,11 +24,6 @@ QSize ProjectTabStrip::sizeHint() const return {int(std::ceil(projectTabOverflow(order, widths(), m_workspace.selectedID(), 0, &OverflowTabsPill::pillWidth).contentWidth())), 34}; } -QSize ProjectTabStrip::minimumSizeHint() const -{ - return {0, 34}; -} - QList ProjectTabStrip::buttons() const { return QList(m_buttons.begin(), m_buttons.end()); diff --git a/src/UI/ProjectTabs.h b/src/UI/ProjectTabs.h index 5e12842..2588904 100644 --- a/src/UI/ProjectTabs.h +++ b/src/UI/ProjectTabs.h @@ -92,7 +92,6 @@ public: OverflowTabsPill *pill() const { return m_pill; } QSize sizeHint() const override; - QSize minimumSizeHint() const override; protected: void resizeEvent(QResizeEvent *event) override; diff --git a/tests/ProjectTabDragTests.cpp b/tests/ProjectTabDragTests.cpp index 32ba357..bbcc187 100644 --- a/tests/ProjectTabDragTests.cpp +++ b/tests/ProjectTabDragTests.cpp @@ -90,6 +90,8 @@ void ProjectTabDragTests::aDraggedTabFollowsThePointerAndLandsInTheGap() // Two points stay a press; three drag, and select. fixture.move(start + QPoint(2, 0)); QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[2]->id); + fixture.move(start + QPoint(3, 0)); + QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id); fixture.move(start + QPoint(40, 0)); QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id); QCOMPARE(first.x(), 40); @@ -104,10 +106,11 @@ void ProjectTabDragTests::aDraggedTabFollowsThePointerAndLandsInTheGap() QVERIFY(slide && slide->duration() == 150 && slide->easingCurve() == QEasingCurve::OutQuad); QCOMPARE(slide->endValue().toPoint(), QPoint(0, 3)); QVERIFY(!first.findChild() && !third.findChild()); + QTRY_VERIFY(settled(fixture.strip)); // The dragged tab lies over the one it crosses. + QVERIFY(first.x() < second.x() + second.width()); QWidget *hit = fixture.strip.childAt(first.x() + 2, 14); QVERIFY(hit == &first || first.isAncestorOf(hit)); - QTRY_VERIFY(settled(fixture.strip)); QCOMPARE(second.x(), 0); QCOMPARE(third.x(), w1 + 6 + w2 + 6); // Far right: held at the row's end, the others compacted. @@ -155,6 +158,12 @@ void ProjectTabDragTests::aSmallOrUpwardMoveIsAClick() fixture.move(start + QPoint(2, -3)); QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[2]->id); QCOMPARE(button(fixture.strip, "Untitled").x(), 0); + // A move with the button let go unseen drags nothing. + QMouseEvent unheld(QEvent::MouseMove, QPointF(fixture.pressed->mapFrom(&fixture.strip, start + QPoint(200, 0))), + QPointF(fixture.pressed->mapToGlobal(fixture.pressed->mapFrom(&fixture.strip, start + QPoint(200, 0)))), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(fixture.pressed, &unheld); + QCOMPARE(button(fixture.strip, "Untitled").x(), 0); fixture.release("Untitled", start + QPoint(2, -3)); // The click selects; the order stays. QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id); From 5bfbc6cd5d95ee34393c12413c50a2e82d8e9e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 17:14:47 -0400 Subject: [PATCH 07/17] =?UTF-8?q?Quit=20settles=20a=20pending=20gradient,?= =?UTF-8?q?=20a=20pixel=20move=20and=20open=20dialogs;=20Dither=20?= =?UTF-8?q?=E2=80=BA=20Scanlines=20(CRT)=20on=20all=20cores=20(13.17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kernels/DitherPixels.c | 163 ++++++++++++- kernels/DitherPixels.h | 10 +- src/Document/Dither.cpp | 67 +++++- src/Document/Dither.h | 19 +- src/Document/ProjectWorkspace.cpp | 32 ++- src/Document/ProjectWorkspace.h | 2 + src/UI/FilterSheet+Dither.cpp | 19 +- tests/DitherSheetTests.cpp | 4 +- tests/DitherTests.cpp | 4 +- tests/QuitPendingEditsTests.cpp | 246 +++++++++++++++++++ tests/ScanlinesTests.cpp | 377 ++++++++++++++++++++++++++++++ tests/Tests.cmake | 2 + 12 files changed, 913 insertions(+), 32 deletions(-) create mode 100644 tests/QuitPendingEditsTests.cpp create mode 100644 tests/ScanlinesTests.cpp diff --git a/kernels/DitherPixels.c b/kernels/DitherPixels.c index 1f33852..caa40e1 100644 --- a/kernels/DitherPixels.c +++ b/kernels/DitherPixels.c @@ -1,9 +1,30 @@ #include "DitherPixels.h" #include #include +#include + +// Linux: GCD's dispatch_apply, run on QtConcurrent by Document/Dither.cpp. Blocks become functions with a context. +void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context); static inline float clamp01(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; } +typedef void (*band_body)(void *context, size_t start, size_t end); +typedef struct { size_t count, size; band_body body; void *context; } band_job; + +static void run_band(void *context, size_t band) { + const band_job *job = context; + size_t count = job->count, size = job->size; + size_t start = band * size, end = start + size < count ? start + size : count; + if (start < end) job->body(job->context, start, end); +} + +// Runs `body` over `count` items split into a few runs per core, each a (start, end) range, all at once. +static void in_bands(size_t count, band_body body, void *context) { + size_t bands = count < 64 ? 1 : 32, size = (count + bands - 1) / bands; + band_job job = { count, size, body, context }; + dither_bands(bands, run_band, &job); +} + // Density darkens (positive) or lightens as a gamma, so black and white stay put; contrast pivots on mid gray. static inline float adjust_tone(float v, float gamma, float contrast) { v = powf(clamp01(v), gamma); @@ -114,19 +135,17 @@ static inline void write_pixel(uint8_t *px, float r, float g, float b) { px[2] = (uint8_t)lroundf(clamp01(b) * a * 255.0f); } -int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) { - size_t count = width * height; - if (!count) return 1; - int planes = p->originalColors ? 3 : 1; - float *tone = malloc(count * sizeof(float) * (size_t)planes); - uint8_t *alpha = malloc(count); - // The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode. - float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL; - if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; } +typedef struct { + uint8_t *rgba; size_t stride, width, count; uint8_t *alpha; float *tone, *source; const DitherParams *p; + float gamma, contrast; +} tone_job; - float gamma = exp2f(p->density * 1.5f); - float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast; - for (size_t y = 0; y < height; ++y) { +static void tone_band(void *context, size_t first, size_t last) { + const tone_job *job = context; + uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, count = job->count; + uint8_t *alpha = job->alpha; float *tone = job->tone, *source = job->source; const DitherParams *p = job->p; + float gamma = job->gamma, contrast = job->contrast; + for (size_t y = first; y < last; ++y) { const uint8_t *row = rgba + y * stride; for (size_t x = 0; x < width; ++x) { const uint8_t *px = row + x * 4; @@ -147,6 +166,90 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons } } } +} + +typedef struct { + uint8_t *rgba; size_t stride, width, height, count; int planes; const uint8_t *alpha; const float *tone; + const DitherParams *p; size_t spacing; float middle, dots; const float *screen, *phosphor; atomic_int failed; +} scan_job; + +static void scan_band(void *context, size_t firstLine, size_t lastLine) { + scan_job *job = context; + uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, height = job->height, count = job->count; + int planes = job->planes; const uint8_t *alpha = job->alpha; const float *tone = job->tone; const DitherParams *p = job->p; + size_t spacing = job->spacing; float middle = job->middle, dots = job->dots; + const float *screen = job->screen, *phosphor = job->phosphor; + float *scan = malloc(width * sizeof(float) * (size_t)planes); + if (!scan) { atomic_store(&job->failed, 1); return; } + for (size_t line = firstLine; line < lastLine; ++line) { + size_t top = line * spacing; + size_t bottom = top + spacing < height ? top + spacing : height; + // Wobble: each line is pushed sideways, a slow wave down the screen with a quicker one over it, as a + // CRT's picture wavers when its sync drifts. + float wave = sinf((float)line * 0.45f) * 0.7f + sinf((float)line * 1.7f + 1.3f) * 0.3f; + long shift = lroundf(p->wobble * wave); + for (size_t x = 0; x < width; ++x) { + float sum[3] = { 0, 0, 0 }; int n = 0; + long sx = (long)x - shift; + if (sx >= 0 && sx < (long)width) + for (size_t y = top; y < bottom; ++y) { + size_t at = y * width + (size_t)sx; + if (!alpha[at]) continue; + for (int c = 0; c < planes; ++c) sum[c] += tone[(size_t)c * count + at]; + ++n; + } + for (int c = 0; c < planes; ++c) scan[(size_t)c * width + x] = n ? sum[c] / (float)n : 0; + } + for (size_t y = top; y < bottom; ++y) { + uint8_t *row = rgba + y * stride; + float offset = fabsf((float)(y - top) + 0.5f - middle); + for (size_t x = 0; x < width; ++x) { + if (!alpha[y * width + x]) continue; + // Dots: the line breaks into beads, one every line spacing, each lit in the color at its middle. + float along = fmodf((float)x + 0.5f, (float)spacing) - middle; + long centered = lroundf((float)x - along * dots); + size_t at = centered < 0 ? 0 : (size_t)centered >= width ? width - 1 : (size_t)centered; + float r, g, b, t; + if (p->originalColors) { + r = scan[at]; g = scan[width + at]; b = scan[2 * width + at]; + t = 0.2126f * r + 0.7152f * g + 0.0722f * b; + } else { + t = scan[at]; + r = screen[0] + (phosphor[0] - screen[0]) * t; + g = screen[1] + (phosphor[1] - screen[1]) * t; + b = screen[2] + (phosphor[2] - screen[2]) * t; + } + // The beam is driven brighter than the picture, making up for the dark screen between lines. + r *= 1.35f; g *= 1.35f; b *= 1.35f; + // Half the beam's height: a thin line in the shadows, most of the way across in the highlights, + // always leaving dark screen between lines. + float beam = middle * (0.2f + 0.5f * sqrtf(clamp01(t))); + float across = along * dots, distance = sqrtf(offset * offset + across * across); + float cover = clamp01(beam - distance + 0.5f); + // Between the lines, the screen: black in Original, else the dark color. + float br = p->originalColors ? 0 : screen[0], bg = p->originalColors ? 0 : screen[1]; + float bb = p->originalColors ? 0 : screen[2]; + write_pixel(row + x * 4, br + (r - br) * cover, bg + (g - bg) * cover, bb + (b - bb) * cover); + } + } + } + free(scan); +} + +int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) { + size_t count = width * height; + if (!count) return 1; + int planes = p->originalColors ? 3 : 1; + float *tone = malloc(count * sizeof(float) * (size_t)planes); + uint8_t *alpha = malloc(count); + // The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode. + float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL; + if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; } + + float gamma = exp2f(p->density * 1.5f); + float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast; + tone_job toning = { rgba, stride, width, count, alpha, tone, source, p, gamma, contrast }; + in_bands(height, tone_band, &toning); float dark[3] = { p->dark[0] / 255.0f, p->dark[1] / 255.0f, p->dark[2] / 255.0f }; float light[3] = { p->light[0] / 255.0f, p->light[1] / 255.0f, p->light[2] / 255.0f }; @@ -183,6 +286,18 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons } } } + } else if (style == DITHER_SCANLINES) { + // A CRT: each line scans the image, its tone along the line the average of the rows it covers. The beam glows + // brighter and blooms thicker where the picture is light, and the screen between lines stays dark. + size_t spacing = (size_t)(p->cell < 2 ? 2 : p->cell); + float middle = (float)spacing / 2, dots = clamp01(p->dots); + // Each line is drawn on its own, so the lines are shared out across the cores. + size_t lines = (height + spacing - 1) / spacing; + const float *screen = dark, *phosphor = light; + scan_job scanning = { rgba, stride, width, height, count, planes, alpha, tone, p, spacing, middle, dots, screen, phosphor, 0 }; + in_bands(lines, scan_band, &scanning); + int failed = atomic_load(&scanning.failed); + if (failed) { free(tone); free(alpha); free(source); return 0; } } else { // Marks (halftone shapes, patterns, glyphs) cover as much of each spot as the tone calls for. On light, they // stand for darkness and are drawn in the dark color; light on dark, the reverse. @@ -277,3 +392,27 @@ void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int } } } + +typedef struct { uint8_t *rgba; const uint8_t *glow; size_t width, stride; float amount; } glow_job; + +static void glow_band(void *context, size_t first, size_t last) { + const glow_job *job = context; + uint8_t *rgba = job->rgba; const uint8_t *glow = job->glow; size_t width = job->width, stride = job->stride; + float amount = job->amount; + for (size_t y = first; y < last; ++y) { + uint8_t *row = rgba + y * stride; + const uint8_t *light = glow + y * stride; + for (size_t x = 0; x < width * 4; x += 4) { + float a = row[x + 3]; + for (int c = 0; c < 3; ++c) { + float v = (float)row[x + c] + (float)light[x + c] * amount * a / 255.0f; + row[x + c] = (uint8_t)lroundf(v > a ? a : v); + } + } + } +} + +void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount) { + glow_job glowing = { rgba, glow, width, stride, amount }; + in_bands(height, glow_band, &glowing); +} diff --git a/kernels/DitherPixels.h b/kernels/DitherPixels.h index 41561c8..dc3769d 100644 --- a/kernels/DitherPixels.h +++ b/kernels/DitherPixels.h @@ -8,7 +8,7 @@ enum { DITHER_ATKINSON, DITHER_FLOYD_STEINBERG, DITHER_BAYER_2, DITHER_BAYER_4, DITHER_BAYER_8, DITHER_DOTS, DITHER_LINES, DITHER_DIAMONDS, - DITHER_PATTERNS, DITHER_GLYPHS + DITHER_PATTERNS, DITHER_GLYPHS, DITHER_SCANLINES }; typedef struct { @@ -20,7 +20,8 @@ typedef struct { // −1…1: darker (more ink) or lighter, and flatter or punchier, before dithering. float density; float contrast; - // Halftone and glyph cells, in pixels, and the halftone screen's angle in radians. + // Halftone and glyph cells, in pixels (scanlines: the distance between lines), and the halftone screen's angle in + // radians. int cell; float angle; // Halftone dots, patterns and glyphs mark the light tones on the dark color instead of the dark on the light. @@ -37,6 +38,9 @@ typedef struct { const uint8_t *glyphs; const float *glyphCoverage; int glyphCount; + // Scanlines: how far each line breaks into round dots (0–1), and how many pixels its wobble pushes it sideways. + float dots; + float wobble; } DitherParams; // Dithers premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) in place. Alpha is kept and fully @@ -45,4 +49,6 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons // Turns each `block` × `block` square of premultiplied RGBA pixels into a round dot in its own color on `gap` (straight // sRGB), like the lit pixels of a dot-matrix screen. The dot's edge is smoothed and alpha is kept. void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap); +// Adds `glow` (premultiplied RGBA, same layout) over the pixels at `amount`, never past their own alpha. +void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount); #endif diff --git a/src/Document/Dither.cpp b/src/Document/Dither.cpp index 24b0bd5..a3853ea 100644 --- a/src/Document/Dither.cpp +++ b/src/Document/Dither.cpp @@ -1,17 +1,28 @@ #include "Document/Dither.h" #include "Document/BrushStroke.h" +#include "Document/PixelAdjust.h" #include "IO/ImageExporter.h" +#include "Rendering/PoolMap.h" #include #include #include #include #include #include +#include #include extern "C" { #include "DitherPixels.h" } +// The kernel's bands, where Swift's kernel calls dispatch_apply. +extern "C" void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context) +{ + std::vector indices(bands); + std::iota(indices.begin(), indices.end(), size_t(0)); + PoolMap::blocking(indices, [band, context](size_t index) { band(context, index); }); +} + // Swift's Dither.swift: the settings, the chunky pixels, the glyphs. namespace { // Swift's Characters: the text's graphemes. @@ -53,6 +64,7 @@ QString rawValue(DitherStyle style) case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds"); case DitherStyle::patterns: return QStringLiteral("Mac Patterns"); case DitherStyle::ascii: return QStringLiteral("ASCII"); + case DitherStyle::scanlines: return QStringLiteral("Scanlines (CRT)"); } throw std::logic_error("unknown dither style"); } @@ -80,7 +92,12 @@ bool isHalftone(DitherStyle style) bool drawsMarks(DitherStyle style) { - return !hasTones(style); + return !hasTones(style) && style != DitherStyle::scanlines; +} + +bool usesPixelSize(DitherStyle style) +{ + return style != DitherStyle::ascii && style != DitherStyle::scanlines; } QString rawValue(DitherPixelShape shape) @@ -105,6 +122,10 @@ DitherSettings DitherSettings::normalized() const result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2)); result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8)); result.textSize = std::round(clamp(textSize, textSizeLow, textSizeHigh, 14)); + result.lineSpacing = std::round(clamp(lineSpacing, lineSpacingLow, lineSpacingHigh, 4)); + result.glow = clamp(glow, 0, 100, 35); + result.dots = clamp(dots, 0, 100, 0); + result.wobble = clamp(wobble, wobbleLow, wobbleHigh, 0); result.angle = clamp(angle, -90, 90, 45); result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2)); result.diffusion = clamp(diffusion, 0, 100, 100); @@ -125,8 +146,8 @@ DitherSettings DitherSettings::normalized() const QImage DitherSettings::apply(const QImage &image) const { const DitherSettings settings = normalized(); - // ASCII draws at full resolution: chunks would blur its letters. - const int block = settings.style == DitherStyle::ascii ? 1 : int(settings.pixelSize); + // ASCII and Scanlines draw full size: chunks would blur them. + const int block = usesPixelSize(settings.style) ? int(settings.pixelSize) : 1; const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); if (source.isNull()) throw ExportError(ExportError::Kind::render); @@ -153,6 +174,8 @@ QImage DitherSettings::apply(const QImage &image) const } } const QImage dithered = settings.dither(working); + if (settings.style == DitherStyle::scanlines && settings.glow > 0) + return settings.glowing(dithered); if (block <= 1) return dithered; // Blown back up without smoothing. @@ -171,9 +194,42 @@ QImage DitherSettings::apply(const QImage &image) const return full; } +// The lines' light blurred wide and added back: phosphor bloom. +QImage DitherSettings::glowing(const QImage &image) const +{ + // Blurred small, scaled back up: same light, less work. + const double sigma = lineSpacing * 3 + 3, shrink = std::max(1.0, std::floor(sigma / 4)); + const int width = image.width(), height = image.height(); + // Swift clamps first: edges repeat outward before shrinking. + const int step = int(shrink), pad = int(std::ceil(sigma * 3 / shrink)) * step; + QImage padded = BrushRaster::context(width + 2 * pad, height + 2 * pad, false); + for (int y = 0; y < padded.height(); ++y) { + const uchar *source = image.constScanLine(std::clamp(y - pad, 0, height - 1)); + uchar *out = padded.scanLine(y); + for (int x = 0; x < padded.width(); ++x) + std::copy_n(source + std::clamp(x - pad, 0, width - 1) * 4, 4, out + x * 4); + } + const int smallWidth = (padded.width() + step - 1) / step, smallHeight = (padded.height() + step - 1) / step; + const QImage small = padded.scaled(smallWidth, smallHeight, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + if (small.isNull()) + throw ExportError(ExportError::Kind::render); + const QImage blurred = PixelAdjust::gaussianBlur(small, sigma / shrink, true) + .scaled(smallWidth * step, smallHeight * step, Qt::IgnoreAspectRatio, Qt::SmoothTransformation) + .convertToFormat(QImage::Format_RGBA8888_Premultiplied); + QImage result = image.copy(); + if (blurred.isNull() || result.isNull()) + throw ExportError(ExportError::Kind::render); + // The scaled light's own rows, cropped to the image. + QImage bloom = BrushRaster::context(width, height, false); + for (int y = 0; y < height; ++y) + std::copy_n(blurred.constScanLine(y + pad) + pad * 4, width * 4, bloom.scanLine(y)); + dither_glow(result.bits(), bloom.constBits(), size_t(width), size_t(height), size_t(result.bytesPerLine()), float(glow / 100 * 2.5)); + return result; +} + QImage DitherSettings::dither(const QImage &image) const { - const int cell = int(cellSize); + const int cell = int(style == DitherStyle::scanlines ? lineSpacing : cellSize); const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, int(textSize)) : Glyphs(); const bool two = colors == DitherColors::twoColors; const std::array darkColor = two ? bytes(dark) : std::array{0, 0, 0}; @@ -184,7 +240,8 @@ QImage DitherSettings::dither(const QImage &image) const .contrast = float(contrast / 100), .cell = cell, .angle = float(angle * M_PI / 180), .lightOnDark = lightOnDark ? 1 : 0, .originalColors = colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]}, .light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphWidth = glyphs.width, .glyphHeight = glyphs.height, .glyphs = glyphs.maps.data(), - .glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())}; + .glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size()), .dots = float(dots / 100), + .wobble = float(wobble)}; failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), ¶ms) == 0; }); if (failed) diff --git a/src/Document/Dither.h b/src/Document/Dither.h index 06f88df..c25ed82 100644 --- a/src/Document/Dither.h +++ b/src/Document/Dither.h @@ -6,10 +6,10 @@ #include // Filter › Dither's looks, in the panel's order and DitherPixels.h's. -enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii }; -inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4, - DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds, - DitherStyle::patterns, DitherStyle::ascii}; +enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii, scanlines }; +inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4, + DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds, + DitherStyle::patterns, DitherStyle::ascii, DitherStyle::scanlines}; QString rawValue(DitherStyle style); // Swift's groups: diffusion, ordered, halftone, marks. int ditherGroup(DitherStyle style); @@ -19,6 +19,8 @@ bool diffuses(DitherStyle style); bool hasTones(DitherStyle style); bool isHalftone(DitherStyle style); bool drawsMarks(DitherStyle style); +// ASCII and Scanlines draw at full resolution, not chunky. +bool usesPixelSize(DitherStyle style); // A chunky pixel: a square, or a dot on dark. enum class DitherPixelShape { square, dot }; @@ -35,6 +37,8 @@ struct DitherSettings { static constexpr double cellSizeLow = 4, cellSizeHigh = 64; static constexpr double textSizeLow = 6, textSizeHigh = 64; static constexpr double levelsLow = 2, levelsHigh = 8; + static constexpr double lineSpacingLow = 2, lineSpacingHigh = 32; + static constexpr double wobbleLow = 0, wobbleHigh = 64; static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); } DitherStyle style = DitherStyle::atkinson; // Each dithered pixel covers this many layer pixels a side. @@ -44,6 +48,12 @@ struct DitherSettings { double cellSize = 8; // ASCII's line height in layer pixels; letters about 0.6 wide. double textSize = 14; + // Scanlines: the lines' distance apart, in layer pixels. + double lineSpacing = 4; + // Glow and dots 0–100%; wobble sideways in pixels. + double glow = 35; + double dots = 0; + double wobble = 0; // The halftone screen's angle, in degrees. double angle = 45; // Tones per channel; 2 is 1-bit. @@ -76,4 +86,5 @@ struct DitherSettings { private: QImage dither(const QImage &image) const; + QImage glowing(const QImage &image) const; }; diff --git a/src/Document/ProjectWorkspace.cpp b/src/Document/ProjectWorkspace.cpp index 6fe8a5f..d7a8c11 100644 --- a/src/Document/ProjectWorkspace.cpp +++ b/src/Document/ProjectWorkspace.cpp @@ -220,14 +220,40 @@ bool ProjectWorkspace::finishTextEditing() return true; } +void ProjectWorkspace::settlePendingEdits(std::function done) +{ + // Canvas edits are applied; dialogs cancelled, as their Cancel. + const std::shared_ptr settled = tab(current().id); + settled->session.commitGradient([self = QPointer(this), settled, done] { + settled->session.finishPixelMove([self, settled, done] { + EditorSession &session = settled->session; + session.cancelFilter(); + session.cancelHueSaturation(); + session.cancelLevels(); + session.cancelColorRange(); + session.setSelectionAmountOperation(std::nullopt); + session.closeColorPicker(false); + if (self) + done(); + }); + }); +} + void ProjectWorkspace::confirmQuit(std::function done) { - if (!finishTextEditing() || !canSwitch()) { + if (m_isManaging || !finishTextEditing()) { finish(done, false); return; } - setManaging(true); - askNext(quitOrder(), 0, std::move(done)); + settlePendingEdits([this, done] { + if (!canSwitch()) { + qCWarning(lcApp) << "Quit refused: the project is still busy"; + finish(done, false); + return; + } + setManaging(true); + askNext(quitOrder(), 0, done); + }); } void ProjectWorkspace::askNext(std::vector> order, size_t index, std::function done) diff --git a/src/Document/ProjectWorkspace.h b/src/Document/ProjectWorkspace.h index f0609c7..1924b17 100644 --- a/src/Document/ProjectWorkspace.h +++ b/src/Document/ProjectWorkspace.h @@ -42,6 +42,8 @@ public: void close(QUuid id, std::function done = {}); void removeTab(QUuid id); std::vector> quitOrder() const; + // Applies canvas edits and cancels dialogs before a quit. + void settlePendingEdits(std::function done); void confirmQuit(std::function done); void closeWindow(QWidget *closing); void receive(const QList &urls, std::optional destination = std::nullopt, diff --git a/src/UI/FilterSheet+Dither.cpp b/src/UI/FilterSheet+Dither.cpp index 5cca135..471011e 100644 --- a/src/UI/FilterSheet+Dither.cpp +++ b/src/UI/FilterSheet+Dither.cpp @@ -48,10 +48,25 @@ void FilterSheet::dither() }); control(QStringLiteral("Pixel Size"), key(&DitherSettings::pixelSize), DitherSettings::pixelSizeLow, DitherSettings::pixelSizeHigh, QStringLiteral("px"), 0, false); box()->setToolTip(QStringLiteral("Make each dithered pixel this many pixels across, for a chunky old-screen look")); - m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style != DitherStyle::ascii; }); + m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return usesPixelSize(settings.dither.style); }); control(QStringLiteral("Text Size"), key(&DitherSettings::textSize), DitherSettings::textSizeLow, DitherSettings::textSizeHigh, QStringLiteral("px"), 0, false); box()->setToolTip(QStringLiteral("The height of each line of characters")); m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::ascii; }); + // Scanlines' four rows. + const auto scanlines = [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::scanlines; }; + control(QStringLiteral("Line Spacing"), key(&DitherSettings::lineSpacing), DitherSettings::lineSpacingLow, DitherSettings::lineSpacingHigh, QStringLiteral("px"), 0, + false); + box()->setToolTip(QStringLiteral("How far apart the screen's lines are")); + m_shownWhen.emplace_back(box(), scanlines); + control(QStringLiteral("Glow"), key(&DitherSettings::glow), 0, 100, QStringLiteral("%"), 0, false); + box()->setToolTip(QStringLiteral("Light blooming around the lines, like a CRT's phosphors")); + m_shownWhen.emplace_back(box(), scanlines); + control(QStringLiteral("Dots"), key(&DitherSettings::dots), 0, 100, QStringLiteral("%"), 0, false); + box()->setToolTip(QStringLiteral("Break the lines into glowing beads")); + m_shownWhen.emplace_back(box(), scanlines); + control(QStringLiteral("Wobble"), key(&DitherSettings::wobble), DitherSettings::wobbleLow, DitherSettings::wobbleHigh, QStringLiteral("px"), 0, false); + box()->setToolTip(QStringLiteral("Make the lines waver sideways down the screen, like a CRT losing sync")); + m_shownWhen.emplace_back(box(), scanlines); control(QStringLiteral("Cell Size"), key(&DitherSettings::cellSize), DitherSettings::cellSizeLow, DitherSettings::cellSizeHigh, QStringLiteral("px"), 0, false); m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style); }); control(QStringLiteral("Angle"), key(&DitherSettings::angle), -90, 90, QStringLiteral("°"), 0, false); @@ -133,7 +148,7 @@ void FilterSheet::dither() const QSignalBlocker quiet(shape); shape->setCurrentIndex(int(settings.dither.pixelShape)); }); - m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && settings.dither.style != DitherStyle::ascii; }); + m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && usesPixelSize(settings.dither.style); }); flag(QStringLiteral("Light on Dark"), QStringLiteral("Draw the marks for the light tones on the dark color, like a glowing screen"), [](FilterSettings &settings) -> bool & { return settings.dither.lightOnDark; }); m_shownWhen.emplace_back(m_flags.back().first, [](const FilterSettings &settings) { return drawsMarks(settings.dither.style); }); diff --git a/tests/DitherSheetTests.cpp b/tests/DitherSheetTests.cpp index 387276e..464e5b1 100644 --- a/tests/DitherSheetTests.cpp +++ b/tests/DitherSheetTests.cpp @@ -179,13 +179,13 @@ void DitherSheetTests::theControlsFollowTheStyle() dither.colors = DitherColors::twoColors; }); QCOMPARE(shown.shown(), sorted(QStringList{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Light on Dark", "Swatches"})); - // Style's menu: ten looks in four groups. + // Style's menu: eleven looks in four groups. QComboBox &style = shown.child("ditherStyle"); QStringList items; for (int index = 0; index < style.count(); ++index) items << (style.itemText(index).isEmpty() ? QStringLiteral("—") : style.itemText(index)); QCOMPARE(items, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "—", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "—", "Halftone Dots", - "Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII"})); + "Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII", "Scanlines (CRT)"})); QCOMPARE(style.currentText(), QString("Mac Patterns")); // Its quick preview never shows the busy line. QVERIFY(!shown.child("filterSpinner").isVisibleTo(shown.sheet.get())); diff --git a/tests/DitherTests.cpp b/tests/DitherTests.cpp index 7177ffd..0471e57 100644 --- a/tests/DitherTests.cpp +++ b/tests/DitherTests.cpp @@ -67,11 +67,11 @@ void DitherTests::stylesKeepSwiftsNamesOrderAndGroups() for (const DitherStyle each : allDitherStyles) names << rawValue(each); QCOMPARE(names, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "Halftone Dots", - "Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII"})); + "Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII", "Scanlines (CRT)"})); QString groups; for (const DitherStyle each : allDitherStyles) groups += QString::number(ditherGroup(each)); - QCOMPARE(groups, QString("0011122233")); + QCOMPARE(groups, QString("00111222333")); QVERIFY(diffuses(DitherStyle::floydSteinberg) && !diffuses(DitherStyle::bayer2)); QVERIFY(hasTones(DitherStyle::bayer8) && !hasTones(DitherStyle::dots)); QVERIFY(isHalftone(DitherStyle::diamonds) && !isHalftone(DitherStyle::patterns)); diff --git a/tests/QuitPendingEditsTests.cpp b/tests/QuitPendingEditsTests.cpp new file mode 100644 index 0000000..206e988 --- /dev/null +++ b/tests/QuitPendingEditsTests.cpp @@ -0,0 +1,246 @@ +#include "DialogDesk.h" +#include "Document/ProjectWorkspace.h" +#include "SelectionFixtures.h" +#include "UI/ProjectWorkspaceView.h" +#include + +// Swift 1.4.4: quit applies canvas edits and cancels dialogs. +namespace { +ImportedImage filled(const QColor &colour) +{ + QImage pixels(100, 20, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(colour); + return ImportedImage(pixels, pixels, QStringLiteral("Fixture")); +} + +// Swift's await: true once the call's callback has run. +bool awaited(const std::function)> &call) +{ + bool done = false; + call([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 10'000); +} + +std::optional answer(ProjectWorkspace &workspace) +{ + std::optional result; + workspace.confirmQuit([&](bool value) { result = value; }); + return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt; +} + +// Swift's test: a gradient waiting for Apply. +void pendGradient(EditorSession &session) +{ + session.createDocument(100, 20); + session.addBlankLayer(); + session.selectTool(NavigationTool::gradient); + session.beginGradient(QPointF(0, 10)); + session.moveGradient(std::nullopt, QPointF(100, 10)); + session.endGradientDrag(); +} + +ImageIdentity identity(const EditorSession &session) +{ + return session.activeLayer().value().asset.value().identity(); +} +} + +class QuitPendingEditsTests : public QObject { + Q_OBJECT +private slots: + void quittingAppliesAPendingGradient(); + void quittingAppliesAPixelMove(); + void quittingCancelsAnOpenDialog(); + void quittingCancelsTheOtherDialogs(); + void quittingCancelsAnAdjustmentsEditor(); + void aWorkspaceGoneMidSettleCallsNothing(); + void closingTheWindowAppliesTheGradientThenAsks(); + void aBusyProjectHoldsTheQuit(); + void aQuitWhileManagingIsRefusedAtOnce(); +}; + +void QuitPendingEditsTests::quittingAppliesAPendingGradient() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + pendGradient(session); + QVERIFY(!workspace.canSwitch()); + const int count = session.history.undoCount(); + QVERIFY(awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); })); + QVERIFY(!session.gradientEdit()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Gradient")); + QVERIFY(workspace.canSwitch()); +} + +void QuitPendingEditsTests::quittingAppliesAPixelMove() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + session.insert(filled(QColor(200, 100, 50))); + session.applySelection(rectPath(QRectF(10, 5, 10, 10)), SelectionMode::replace, "Select"); + QVERIFY(session.beginPixelMove()); + session.movePixels(QSizeF(30, 0)); + QVERIFY(!workspace.canSwitch()); + const int count = session.history.undoCount(); + QVERIFY(awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); })); + QVERIFY(!session.pixelMove()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Move Pixels")); + QCOMPARE(session.selection().value().path.boundingRect(), QRectF(40, 5, 10, 10)); + QVERIFY(workspace.canSwitch()); +} + +void QuitPendingEditsTests::quittingCancelsAnOpenDialog() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + session.insert(filled(QColor(200, 100, 50))); + const ImageIdentity original = identity(session); + const int count = session.history.undoCount(); + const std::vector> opens{[&] { session.beginFilter(FilterKind::gaussianBlur); }, + [&] { session.beginHueSaturation(); }, [&] { session.beginLevels(); }}; + for (const std::function &open : opens) { + open(); + QVERIFY(!workspace.canSwitch()); + QVERIFY(awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); })); + QVERIFY(workspace.canSwitch()); + QVERIFY(identity(session) == original); + } + QVERIFY(!session.filterEdit() && !session.hueSaturation() && !session.levels()); + QCOMPARE(session.history.undoCount(), count); +} + +void QuitPendingEditsTests::quittingCancelsTheOtherDialogs() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + session.insert(filled(QColor(200, 100, 50))); + const QPainterPath before = rectPath(QRectF(10, 5, 10, 10)); + session.applySelection(before, SelectionMode::replace, "Select"); + const PaletteColor foreground = session.foregroundColor(); + const auto settle = [&] { return awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); }); }; + // Color Range: the selection goes back as it was. + session.beginColorRange(); + QVERIFY(session.colorRange() && !workspace.canSwitch()); + QVERIFY(settle()); + QVERIFY(!session.colorRange()); + QCOMPARE(session.selection().value().path.boundingRect(), before.boundingRect()); + // Expand, Contract or Feather: its panel goes. + session.setSelectionAmountOperation(SelectionAmountOperation::feather); + QVERIFY(!workspace.canSwitch()); + QVERIFY(settle()); + QVERIFY(!session.selectionAmountOperation()); + // The colour picker: closed, its colour not taken. + session.openColorPicker(false); + session.setColorPickerHSB(PickerHSB(120, 1, 1)); + QVERIFY(!workspace.canSwitch()); + QVERIFY(settle()); + QVERIFY(!session.colorPicker()); + QVERIFY(session.foregroundColor() == foreground); + QVERIFY(workspace.canSwitch()); +} + +void QuitPendingEditsTests::quittingCancelsAnAdjustmentsEditor() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + session.insert(filled(Qt::white)); + session.addAdjustment(AdjustmentKind::exposure); + const QUuid id = session.adjustmentEditingID().value(); + const LayerAdjustment made = session.activeLayer().value().adjustment.value(); + QVERIFY(awaited([&](std::function done) { session.beginAdjustmentEditing(id, std::move(done)); })); + FilterSettings settings = session.filterEdit().value().settings; + settings.exposure.exposure = 2; + session.updateFilter(settings, true); + QVERIFY(!(session.activeLayer().value().adjustment.value() == made)); + QVERIFY(!workspace.canSwitch()); + QVERIFY(awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); })); + QVERIFY(!session.adjustmentEditingID() && !session.adjustmentOriginal() && !session.filterEdit()); + QVERIFY(session.activeLayer().value().adjustment.value() == made); + QVERIFY(workspace.canSwitch()); +} + +void QuitPendingEditsTests::aWorkspaceGoneMidSettleCallsNothing() +{ + auto workspace = std::make_unique(); + const std::shared_ptr tab = workspace->tabs().front(); + pendGradient(tab->session); + bool called = false; + workspace->settlePendingEdits([&called] { called = true; }); + workspace.reset(); + // The tab lives on and its gradient lands; no answer. + QTRY_VERIFY(!tab->session.gradientEdit()); + QTest::qWait(100); + QVERIFY(!called); + QCOMPARE(tab->session.history.undoName(), QString("Gradient")); +} + +void QuitPendingEditsTests::closingTheWindowAppliesTheGradientThenAsks() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView view(workspace); + view.show(); + EditorSession &session = workspace.current().session; + pendGradient(session); + session.history.markSaved(); + DialogDesk desk; + desk.replies = {"Cancel"}; + // The gradient is a step by the time it asks. + desk.note = [&] { return QStringLiteral("pending=%1 step=%2").arg(session.gradientEdit().has_value()).arg(session.history.undoName()); }; + view.close(); + QTRY_COMPARE(desk.seen.size(), 1); + QVERIFY2(desk.seen[0].startsWith("alert|2|Save changes to Untitled?") && desk.seen[0].endsWith("|pending=0 step=Gradient"), + qPrintable(desk.seen[0])); + QTRY_VERIFY(!workspace.isManaging()); + QVERIFY(view.isVisible()); +} + +void QuitPendingEditsTests::aBusyProjectHoldsTheQuit() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + pendGradient(session); + session.setIsProjectBusy(true); + DialogDesk desk; + QTest::ignoreMessage(QtWarningMsg, "Quit refused: the project is still busy"); + QCOMPARE(answer(workspace), std::optional(false)); + // Nothing asked, nothing applied: the gradient waits. + QVERIFY(desk.seen.isEmpty()); + QVERIFY(session.gradientEdit()); + QVERIFY(!workspace.isManaging()); + session.setIsProjectBusy(false); +} + +void QuitPendingEditsTests::aQuitWhileManagingIsRefusedAtOnce() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + DialogDesk desk; + desk.replies = {"Cancel"}; + std::optional second; + bool settled = false; + bool answered = false; + // While the first quit asks, a second is refused unasked. + desk.note = [&] { + session.setSelectionAmountOperation(SelectionAmountOperation::feather); + workspace.confirmQuit([&](bool value) { second = value; }); + answered = QTest::qWaitFor([&] { return second.has_value(); }, 5000); + settled = !session.selectionAmountOperation(); + session.setSelectionAmountOperation(std::nullopt); + return QString(); + }; + QCOMPARE(answer(workspace), std::optional(false)); + QCOMPARE(desk.seen.size(), 1); + QVERIFY(answered); + QCOMPARE(second, std::optional(false)); + QVERIFY(!settled); +} + +QTEST_MAIN(QuitPendingEditsTests) +#include "QuitPendingEditsTests.moc" diff --git a/tests/ScanlinesTests.cpp b/tests/ScanlinesTests.cpp new file mode 100644 index 0000000..45f31ce --- /dev/null +++ b/tests/ScanlinesTests.cpp @@ -0,0 +1,377 @@ +#include "Document/BrushStroke.h" +#include "Document/Dither.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "UI/FilterSheet.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// Swift 1.4.4's Dither › Scanlines (CRT), on every core. +namespace { +QImage filled(int width, int height, QColor colour) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(colour); + return image; +} + +DitherSettings scanlines(double spacing, double glow = 0) +{ + DitherSettings settings; + settings.style = DitherStyle::scanlines; + settings.lineSpacing = spacing; + settings.glow = glow; + return settings; +} + +// One channel of one column, top to bottom. +std::vector column(const QImage &image, int x, int channel = 0) +{ + std::vector values; + for (int y = 0; y < image.height(); ++y) + values.push_back(image.constScanLine(y)[x * 4 + channel]); + return values; +} + +std::vector row(const QImage &image, int y, int channel = 0) +{ + std::vector values; + for (int x = 0; x < image.width(); ++x) + values.push_back(image.constScanLine(y)[x * 4 + channel]); + return values; +} + +// Swift's test: one column of a flat gray's render. +std::vector gray(int level, double spacing, double glow = 0) +{ + return column(scanlines(spacing, glow).apply(filled(16, 32, QColor(level, level, level))), 5); +} + +std::vector repeated(const std::vector &band, int times) +{ + std::vector all; + for (int each = 0; each < times; ++each) + all.insert(all.end(), band.begin(), band.end()); + return all; +} + +struct Shown { + EditorSession session; + std::unique_ptr sheet; + Shown() + { + session.createDocument(40, 30); + const QImage image = filled(40, 30, QColor(200, 120, 40)); + session.insert(ImportedImage(image, image, QStringLiteral("Colour"))); + session.beginFilter(FilterKind::dither); + FilterSettings settings = session.filterEdit().value().settings; + settings.dither.style = DitherStyle::scanlines; + session.updateFilter(settings, true); + sheet = std::make_unique(session); + sheet->show(); + } + const DitherSettings &dither() const { return session.filterEdit().value().settings.dither; } + // The shown rows in the column's order, with their help. + QStringList rows() const + { + QStringList titles; + QLayout *column = sheet->layout(); + for (int index = 0; index < column->count(); ++index) { + QWidget *each = column->itemAt(index)->widget(); + if (!each || each->isHidden()) + continue; + if (auto *box = qobject_cast(each); box && box->text() != QLatin1String("Preview")) + titles << box->text(); + for (QLabel *label : each->findChildren(QString(), Qt::FindDirectChildrenOnly)) { + if (label->buddy()) + titles << label->text() + u'|' + each->toolTip(); + } + } + return titles; + } +}; +} + +class ScanlinesTests : public QObject { + Q_OBJECT +private slots: + void scanlinesAreLinesOfLightThatBloomWithBrightness(); + void glowLightsBetweenTheLines(); + void dotsBreakTheLinesIntoBeads(); + void wobbleMovesLinesSideways(); + void theStyleDrawsAtFullResolutionWithoutMarks(); + void normalizingClampsTheFourSettings(); + void eachLineIsTheAverageOfItsRows(); + void theScreenIsTheDarkColourOrBlack(); + void clearPixelsStayClear(); + void everyBandOfATallImageIsDrawn(); + void theGlowIsTheBlurredLightAtItsAmount(); + void theGlowNeverPassesAlpha(); + void theSheetShowsScanlinesRows(); + void theRowsWriteTheirSettings(); +}; + +void ScanlinesTests::scanlinesAreLinesOfLightThatBloomWithBrightness() +{ + const std::vector white = gray(255, 8), dim = gray(89, 8), black = gray(0, 8); + for (int band = 0; band < 4; ++band) { + const std::vector rows(white.begin() + band * 8, white.begin() + band * 8 + 8); + QVERIFY(rows[3] == 255 && rows[4] == 255); + QVERIFY(std::any_of(rows.begin(), rows.end(), [](int value) { return value < 40; })); + } + QVERIFY(std::accumulate(dim.begin(), dim.end(), 0) * 2 < std::accumulate(white.begin(), white.end(), 0)); + QVERIFY(std::all_of(black.begin(), black.end(), [](int value) { return value == 0; })); + // The beam's half height, 2.8 white, about 1.98 at 0.35. + QCOMPARE(white, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 4)); + QCOMPARE(dim, repeated({0, 0, 118, 120, 120, 118, 0, 0}, 4)); +} + +void ScanlinesTests::glowLightsBetweenTheLines() +{ + const std::vector plain = gray(255, 8), glowing = gray(255, 8, 100); + QVERIFY2(glowing[0] > plain[0] + 40, qPrintable(QString::number(glowing[0]))); +} + +void ScanlinesTests::dotsBreakTheLinesIntoBeads() +{ + const QImage white = filled(64, 16, Qt::white); + DitherSettings settings = scanlines(8); + QCOMPARE(row(settings.apply(white), 4), std::vector(64, 255)); + settings.dots = 100; + // Each bead lit at its middle, dark at its ends. + const std::vector lit = row(settings.apply(white), 4); + QCOMPARE(std::vector(lit.begin(), lit.begin() + 8), (std::vector{0, 255, 255, 255, 255, 255, 255, 0})); + QCOMPARE(std::vector(lit.begin() + 8, lit.begin() + 16), (std::vector{0, 255, 255, 255, 255, 255, 255, 0})); + // Half dots: the bead ends dim off the middle. + settings.dots = 50; + QCOMPARE(row(settings.apply(white), 1)[0], 85); +} + +void ScanlinesTests::wobbleMovesLinesSideways() +{ + QImage edge = filled(64, 64, Qt::black); + QPainter(&edge).fillRect(QRect(32, 0, 32, 64), Qt::white); + const auto edges = [&edge](double wobble) { + DitherSettings settings = scanlines(8); + settings.wobble = wobble; + const QImage result = settings.apply(edge); + std::vector found; + for (int line = 0; line < 8; ++line) { + const std::vector middle = row(result, line * 8 + 4); + found.push_back(int(std::find_if(middle.begin(), middle.end(), [](int value) { return value > 128; }) - middle.begin())); + } + return found; + }; + QCOMPARE(edges(0), std::vector(8, 32)); + // Each line's shift: 12 times the two waves, rounded. + QCOMPARE(edges(12), (std::vector{35, 36, 35, 41, 44, 37, 32, 34})); +} + +void ScanlinesTests::theStyleDrawsAtFullResolutionWithoutMarks() +{ + QVERIFY(!usesPixelSize(DitherStyle::scanlines) && !usesPixelSize(DitherStyle::ascii) && usesPixelSize(DitherStyle::patterns)); + QVERIFY(!drawsMarks(DitherStyle::scanlines) && drawsMarks(DitherStyle::ascii)); + QVERIFY(!hasTones(DitherStyle::scanlines) && !isHalftone(DitherStyle::scanlines) && ditherGroup(DitherStyle::scanlines) == 3); + // A pixel size changes nothing: no chunks, no dots. + const QImage image = filled(20, 24, QColor(180, 90, 30)); + DitherSettings settings = scanlines(4); + const QImage plain = settings.apply(image); + settings.pixelSize = 5; + settings.pixelShape = DitherPixelShape::dot; + QCOMPARE(settings.apply(image), plain); + // The cell is the line spacing, not the cell size. + settings.cellSize = 32; + QCOMPARE(settings.apply(image), plain); +} + +void ScanlinesTests::normalizingClampsTheFourSettings() +{ + DitherSettings settings; + QCOMPARE(settings.lineSpacing, 4.0); + QCOMPARE(settings.glow, 35.0); + QCOMPARE(settings.dots, 0.0); + QCOMPARE(settings.wobble, 0.0); + settings.lineSpacing = 1.4; + settings.glow = 150; + settings.dots = -5; + settings.wobble = 3.5; + DitherSettings result = settings.normalized(); + QCOMPARE(result.lineSpacing, 2.0); + QCOMPARE(result.glow, 100.0); + QCOMPARE(result.dots, 0.0); + QCOMPARE(result.wobble, 3.5); + settings.lineSpacing = 40; + settings.glow = -1; + settings.dots = 120; + settings.wobble = 70; + result = settings.normalized(); + QCOMPARE(result.lineSpacing, 32.0); + QCOMPARE(result.glow, 0.0); + QCOMPARE(result.dots, 100.0); + QCOMPARE(result.wobble, 64.0); + // Rounded within; not a number takes the default. + settings.lineSpacing = 5.5; + settings.glow = std::nan(""); + settings.dots = std::nan(""); + settings.wobble = std::nan(""); + result = settings.normalized(); + QCOMPARE(result.lineSpacing, 6.0); + QCOMPARE(result.glow, 35.0); + QCOMPARE(result.dots, 0.0); + QCOMPARE(result.wobble, 0.0); + settings.lineSpacing = std::nan(""); + QCOMPARE(settings.normalized().lineSpacing, 4.0); + // Glow and dots keep their fractions. + settings.glow = 12.5; + settings.dots = 33.3; + QCOMPARE(settings.normalized().glow, 12.5); + QCOMPARE(settings.normalized().dots, 33.3); +} + +void ScanlinesTests::eachLineIsTheAverageOfItsRows() +{ + // Rows of white and black: each line lights at half. + QImage stripes = filled(8, 16, Qt::black); + for (int y = 0; y < 16; y += 2) + QPainter(&stripes).fillRect(QRect(0, y, 8, 1), Qt::white); + QCOMPARE(column(scanlines(8).apply(stripes), 3), repeated({0, 37, 172, 172, 172, 172, 37, 0}, 2)); +} + +void ScanlinesTests::theScreenIsTheDarkColourOrBlack() +{ + const QImage image = filled(8, 8, QColor(255, 128, 0)); + DitherSettings settings = scanlines(8); + settings.colors = DitherColors::twoColors; + settings.dark = AdjustmentColor(0, 0, 1); + settings.light = AdjustmentColor(1, 1, 0); + const QImage two = settings.apply(image); + // Off the line the dark colour; on it, the mix. + QCOMPARE(two.pixelColor(2, 0), QColor(0, 0, 255)); + QCOMPARE(two.pixelColor(2, 4), QColor(197, 197, 147)); + settings.colors = DitherColors::original; + const QImage original = settings.apply(image); + QCOMPARE(original.pixelColor(2, 0), QColor(0, 0, 0)); + QCOMPARE(original.pixelColor(2, 4), QColor(255, 173, 0)); +} + +void ScanlinesTests::clearPixelsStayClear() +{ + QImage image = filled(8, 16, Qt::white); + QPainter painter(&image); + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.fillRect(QRect(0, 0, 8, 8), Qt::transparent); + painter.end(); + const QImage result = scanlines(8, 100).apply(image); + QCOMPARE(column(result, 2, 3), (std::vector{0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255})); + QCOMPARE(column(result, 2, 0)[4], 0); +} + +void ScanlinesTests::everyBandOfATallImageIsDrawn() +{ + // 150 two-row lines: 32 bands of 5, two empty. + const QImage image = filled(3, 300, Qt::white); + QCOMPARE(column(scanlines(2).apply(image), 1), repeated({241, 241}, 150)); + // 70 lines of 8: bands of 3, the last one. + const std::vector tall = column(scanlines(8).apply(filled(3, 560, Qt::white)), 1); + QCOMPARE(tall, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 70)); +} + +void ScanlinesTests::theGlowIsTheBlurredLightAtItsAmount() +{ + QImage image = filled(64, 160, Qt::black); + QPainter(&image).fillRect(QRect(0, 0, 32, 40), Qt::white); + const QImage lines = scanlines(8).apply(image); + // Swift's first glow: the lines blurred whole, sigma 27. + const QImage blurred = PixelAdjust::gaussianBlur(lines, 8 * 3 + 3, true); + for (const double glow : {20.0, 100.0}) { + const QImage result = scanlines(8, glow).apply(image); + const float amount = float(glow / 100 * 2.5); + int worst = 0; + for (int y = 0; y < 160; ++y) { + for (int x = 0; x < 64; ++x) { + const int plain = lines.constScanLine(y)[x * 4], light = blurred.constScanLine(y)[x * 4]; + const int expected = int(std::lround(std::min(255.0f, float(plain) + float(light) * amount))); + worst = std::max(worst, std::abs(result.constScanLine(y)[x * 4] - expected)); + } + } + // Three levels of the shrink's blur, times 2.5. + QVERIFY2(worst <= 8, qPrintable(QString::number(worst))); + } + // Below the light, at amount 0.5, it fades. + const std::vector fading = column(scanlines(8, 20).apply(image), 16); + QVERIFY(fading[48] > 20 && fading[48] < fading[40] && fading[120] < 3); +} + +void ScanlinesTests::theGlowNeverPassesAlpha() +{ + QImage image = BrushRaster::context(16, 32, false); + image.fill(QColor(255, 255, 255, 128)); + const QImage result = scanlines(4, 100).apply(image); + for (int y = 0; y < 32; ++y) { + for (int x = 0; x < 16; ++x) { + const uchar *at = result.constScanLine(y) + x * 4; + QCOMPARE(int(at[3]), 128); + QVERIFY(at[0] <= 128 && at[1] <= 128 && at[2] <= 128); + } + } + QCOMPARE(int(result.constScanLine(0)[0]), 128); +} + +void ScanlinesTests::theSheetShowsScanlinesRows() +{ + Shown shown; + QCOMPARE(shown.rows(), (QStringList{"Style|", "Line Spacing|How far apart the screen's lines are", + "Glow|Light blooming around the lines, like a CRT's phosphors", "Dots|Break the lines into glowing beads", + "Wobble|Make the lines waver sideways down the screen, like a CRT losing sync", + "Density|More ink (darker) or less before dithering", "Contrast|", "Colors|"})); + // Each slider's travel reaches its range's ends. + const std::tuple sliders[] = { + {"lineSpacingSlider", &DitherSettings::lineSpacing, 2, 32}, {"glowSlider", &DitherSettings::glow, 0, 100}, + {"dotsSlider", &DitherSettings::dots, 0, 100}, {"wobbleSlider", &DitherSettings::wobble, 0, 64}}; + for (const auto &[name, setting, low, high] : sliders) { + QSlider *slider = shown.sheet->findChild(QString::fromLatin1(name)); + QVERIFY2(slider, name); + slider->setValue(slider->maximum()); + QCOMPARE(shown.dither().*setting, high); + slider->setValue(slider->minimum()); + QCOMPARE(shown.dither().*setting, low); + } + // Pixel Size and Pixel Shape stay away under Scanlines. + FilterSettings settings = shown.session.filterEdit().value().settings; + settings.dither.pixelSize = 4; + shown.session.updateFilter(settings, true); + QVERIFY(!shown.rows().join(',').contains("Pixel")); + settings.dither.style = DitherStyle::bayer4; + shown.session.updateFilter(settings, true); + QVERIFY(shown.rows().contains("Pixel Shape|Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen")); + QVERIFY(!shown.rows().join(',').contains("Glow")); +} + +void ScanlinesTests::theRowsWriteTheirSettings() +{ + Shown shown; + const std::tuple rows[] = { + {"lineSpacingField", &DitherSettings::lineSpacing, "12", 12}, {"glowField", &DitherSettings::glow, "60", 60}, + {"dotsField", &DitherSettings::dots, "25", 25}, {"wobbleField", &DitherSettings::wobble, "9", 9}}; + for (const auto &[name, setting, typed, expected] : rows) { + const DitherSettings before = shown.dither(); + QLineEdit *field = shown.sheet->findChild(QString::fromLatin1(name)); + QVERIFY2(field, name); + field->setFocus(); + field->selectAll(); + QTest::keyClicks(field, QString::fromLatin1(typed)); + QTest::keyClick(field, Qt::Key_Return); + DitherSettings wanted = before; + wanted.*setting = expected; + QVERIFY2(shown.dither() == wanted, name); + } +} + +QTEST_MAIN(ScanlinesTests) +#include "ScanlinesTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 80012e2..522dac3 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -166,6 +166,8 @@ compositor_test(ProjectReceiveTests) compositor_test(ProjectReplaceTests) compositor_test(ProjectWorkspaceFlowTests) compositor_test(QuitWhileEditingTextTests) +compositor_test(QuitPendingEditsTests) +compositor_test(ScanlinesTests) compositor_test(OpenRecentTests) compositor_test(ColoredSliderTests) compositor_test(SheetTeardownTests) From 91dc6f2252355ddfa971ad211834ce27d550c574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 17:25:57 -0400 Subject: [PATCH 08/17] Tab drags: the press measured where it lands; another button's move drags nothing (13.14) --- tests/ProjectTabDragTests.cpp | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/ProjectTabDragTests.cpp b/tests/ProjectTabDragTests.cpp index bbcc187..a72500c 100644 --- a/tests/ProjectTabDragTests.cpp +++ b/tests/ProjectTabDragTests.cpp @@ -48,7 +48,7 @@ struct Strip { ProjectTabButton &tab = button(strip, title); const QPoint point = tab.mapTo(&strip, QPoint(20, 14)); pressed = &select(tab); - QTest::mousePress(pressed, Qt::LeftButton, {}, QPoint(20 - pressed->x(), 14)); + QTest::mousePress(pressed, Qt::LeftButton, {}, pressed->mapFrom(&strip, point)); return point; } void move(QPoint point) { QTest::mouseMove(pressed, pressed->mapFrom(&strip, point)); } @@ -158,11 +158,10 @@ void ProjectTabDragTests::aSmallOrUpwardMoveIsAClick() fixture.move(start + QPoint(2, -3)); QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[2]->id); QCOMPARE(button(fixture.strip, "Untitled").x(), 0); - // A move with the button let go unseen drags nothing. - QMouseEvent unheld(QEvent::MouseMove, QPointF(fixture.pressed->mapFrom(&fixture.strip, start + QPoint(200, 0))), - QPointF(fixture.pressed->mapToGlobal(fixture.pressed->mapFrom(&fixture.strip, start + QPoint(200, 0)))), Qt::NoButton, - Qt::NoButton, Qt::NoModifier); - QApplication::sendEvent(fixture.pressed, &unheld); + // Its release lost, a move under another button drags nothing. + const QPoint far = fixture.pressed->mapFrom(&fixture.strip, start + QPoint(200, 0)); + QMouseEvent other(QEvent::MouseMove, QPointF(far), QPointF(fixture.pressed->mapToGlobal(far)), Qt::NoButton, Qt::RightButton, Qt::NoModifier); + QApplication::sendEvent(fixture.pressed, &other); QCOMPARE(button(fixture.strip, "Untitled").x(), 0); fixture.release("Untitled", start + QPoint(2, -3)); // The click selects; the order stays. From 77afa0c9bafde0017e6a5fd53eb7d58a493ba40e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 17:30:07 -0400 Subject: [PATCH 09/17] AGENTS and TASKS for 13.7, 13.14, 13.15 --- AGENTS.md | 7 +++++-- TASKS.md | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3010bb3..5dfcda4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,7 +135,7 @@ Platform adaptations, each forced by a missing Qt analog: - Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held. - Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. Known difference, Swift's too: on the smooth path the canvas's noise follows the view, as Grain's does; on the crisp path the surface's region starts at the repaint region, so each partial repaint there draws a noise field of its own. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds a pixel wider. Tests: `BlurNoiseAdjustmentTests` (the model, manifest, store, seeds, the editor, the filters at a scale, the noise field, a scaled target, the canvas's zoom and a partial repaint's halo at 100%, 150% and 400%), `ImageTrimTests` (Swift's six, tolerance, the sheet, the controller through its sheet, a failure's alert), `InnerGlowTests` (Swift's, but the parity test, and the order), the new kinds in `LayerAdjustmentTests`, `AdjustmentEditorTests`, `AdjustmentLayerTests` and `AdjustmentPanelTests`, the glow in `LayerEffectsKernelTests`' replay, `LayerEffectsSurfaceTests`, `EffectsPreviewCacheTests`, `LayerEffectsTests` and `EffectsPanelTests`, version 9 across the store's tests, Trim in `CompositorMenusTests`. - The finishing filters (12.9a, Swift 1.2.4, `e08dd8c`): `kernels/AdjustPixels.c/.h` are v1.2.4's, with `adjust_colored_vignette` (Camera Raw's falloff pulled out as `vignette_mask`, Camera Raw's own vignette unchanged) and `adjust_tonal_contrast`. `FilterKind` gains Vignette, Bloom / Glow and Tonal Contrast after Add Noise, Swift's order, so the Filter menu lists them; `FilterSettings` keeps Swift's thirteen fields, defaults and clamps (the colour through `AdjustmentColor::clamped`). Vignette and Tonal Contrast hand the kernels every setting; Tonal Contrast's base is `PixelAdjust::gaussianBlur` at radius × scale, clear past the edge, Swift's unclamped `applyingGaussianBlur`. Known difference: Core Image's `CIBloom` is private, so the twin's Bloom / Glow is the pixels plus their Gaussian glow (radius × scale, clear past the edge) at amount ÷ 50, Swift's intensity, added byte by byte on the premultiplied values and rounded once, alpha included, so the glow shows past a clear layer's light as Swift's test asks; Core Image's own curve may differ. Bloom grows the grid by three radii and two and is trimmed on commit, as the blurs are. A commit with nothing to change closes as Cancel does: Vignette or Bloom at amount 0, Tonal Contrast at amount 0 or with all three tones at 0. Every filter's commit now keeps the layer's effects (Swift's rebuild gained `effects: current.effects`); shape and text still drop. `FilterEdit::previewSourceVersion` is Swift's: `prepare` moves it with each new grid, the worker hands back the version it rendered from, and a result for an older grid lands nothing, its waiting job rendering next (a render begun before `growForBlur` would otherwise show the old grid's pixels in the new placement). The twin's one watcher can hold a job waiting behind another edit's worker, which Swift's per-edit task never does, and Preview off keeps that job: a growth therefore rebuilds a waiting job on the new grid, so the version it starts with names its image. The picker gains Swift's Vignette target (`openVignetteColorPicker`, `previewVignetteColor`, "Color Picker (Vignette Color)"), closed with its colour by OK and without by Cancel, as the Gradient Map's; Swift's `committing` terms are left out as the Gradient Map's are (a commit keeps the project busy and closes the picker first), and so is `setVignetteColor`'s edit guard, read through `value()`: the Vignette picker lives only while its edit is open (Cancel and OK close it first), where the Gradient Map's may outlive an adjustment's editor. `UI/FilterSheet+Finishing.cpp` holds the three kinds' rows, FilterSheet.cpp running to 442 lines: Vignette's Color row (a 95-point title, the Gradient Map's 24-point swatch), Amount and Highlights with Swift's help, Midpoint, Roundness (no unit), Feather; Bloom's Amount and a logarithmic Radius; Tonal Contrast's Amount, Shadows, Midtones, Highlights and a logarithmic Radius. The sheet hands the picker's working colour to the Vignette and repaints the swatch on every change. Tests: `FinishingFilterTests` (Swift's five, then the kernels at a scale, the settings' bounds, the idle commits, Bloom's grid, a preview for an older grid, the picker, the sheet), the kinds in `FilterModelTests`, a filter keeping effects in `LayerEffectsTests`, the Filter menu in `CompositorMenusTests`. -- Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). A new front tab scrolls into view, posted as Swift posts its reveal, once the row is laid out; any other change keeps the offset, which Qt clamps, where the strip used to follow every layout (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`. +- Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). Since 13.14 the strip no longer scrolls: tabs that do not fit gather under the overflow pill and the front tab is always shown (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`. - The format version in one place and the layer context menu (12.10a, Swift 1.2.5: `4cc9951`; `6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`, `5d7adba`). `ProjectManifest::current` (9) is what saves write and `supported` (1 to it) what loads accept; the manifest's default, both version checks (`supports`) and the version error's range read them, so a bump is one edit. Swift moved the layer menu from each cell to the table; the twin moves it from `LayerCell::actions()` to the list: `NativeLayerList::contextMenu` is Swift's `contextMenu(for:)` (a row pressed with nothing selected is selected first; Duplicate Layer, Rename… for one layer alone, Delete Layer, Delete Selected Layers or Delete Mask, Create or Release Clipping Mask, Group Selected Layers, Move Out of Folder, the merge entry under `mergeTitle`, Add Mask with Reveal All (White) and Hide All (Black), Enable or Disable Mask, Delete Mask, Link or Unlink Mask, Show or Hide Layer, with Swift's separators and each entry enabled as Swift's `validateMenuItem`, the mask entries targeting the pixels first; 1.2.5 left Layer Effects out; a selection refused (Levels open) leaves no active layer, and every entry then rests); `menuFor` is Swift's `menu(for:)`, routing the press first: on an effect's row that effect is chosen (Swift's `selectEffect(at:)`), elsewhere the chosen effect goes, a row counts as selected only while no effect is chosen (`selectedEffect`, which a stale choice an undo left fails, as the row's painting reads it; Swift's table then shows none), the pressed row's id is read first (Swift's `targetLayer`), since a selection may finish text and rebuild the rows, a selected row keeps the selection with the pressed layer primary unless a thumbnail targets its mask or pixels, and an unselected row becomes the selection, the list takes the keys and its mask or pixels are targeted. `LayerCell::contextMenuEvent` executes that menu; below the rows none shows. Left out as dead: Delete Mask's `mask != nil` (the session chooses a mask only while it exists) and the routing's `effectSelection = nil` (`selectLayers` and `selectLayerTarget`, which every path calls, drop a chosen effect themselves). The entries act on the layer active when chosen, as Swift's `@objc` actions read it, where the old cell menu acted on its own row; Rename asks `canEditLayers` again then, as Swift's `renameSelectedLayer`. Tests: `LayerContextMenuTests` (Swift's regression tests, the folder duplicate included; titles; a real right-click), `LayerContextMenuEdgeTests` (the gates at their edges, a refused selection, action-time targets and an entry with no active layer, a chosen or stale effect, a rebuild under the press), `LayerContextMenuFixtures.h`, `theCurrentFormatVersionIsOneTheReaderAccepts` in `ProjectTests`, the old cell-menu cases in `NativeLayerListTests` and `LayersPanelTests` moved to the list's menu. - Whole layers on the clipboard and several duplicates (12.10b, Swift 1.2.5: `5ef5b0a`, `c873453`, `245db93`, `2f1537f`). `CopiedLayer` (in `Document/SelectionClipboard.h`) is the session's `copiedLayer`: Copy with no selection and no chosen mask (`canCopyLayer`) takes the selected layers whole; a layer with pixels still copies them as well, for other apps, and a folder or adjustment puts only its id on the clipboard under Swift's `com.compositor.copied-layer`, dropping the pixel copy. Any copy of pixels drops it; it is judged as a pixel copy is (`clipboardHolds`), so a copy anywhere since, in this app or another, ends it for good, a history entry set again included, and Wayland's second announcement of the same copy does not. The copies' active one is the active layer's copy, else the first id's with a copy in order (Swift's `values.first` is any). `ProjectWorkspace::pasteCopiedLayer` finds the tab whose copy still stands, keeps the ids its document still holds, and duplicates them in place as one `Paste` step, refused while that project cannot edit its layers, or copies them into the front tab through `copyLayers`; false leaves Paste to pixels. The Edit menu's Copy runs with `canCopyPixels` or `canCopyLayer`, and Paste asks the workspace first, a text field and open text before both. Ctrl+J without a selection duplicates a folder too (`layerViaCopy`), and its entry is enabled for any active layer; a folder under a selection copies nothing. `beginDuplicateTransform` stacks its copies through `duplicateLayers`, the copies being what is selected after it that was not before, the active copy leading. The copy between projects moved to `ProjectWorkspace+Copy.cpp`, since the workspace's file reached 500 lines. Tests: `LayerClipboardTests` (Swift has none), the updated folder cases in `SelectionClipboardTests`, `CompositorMenusTests` and `TransformTests`, and `effectsTravelWithTheCopy` in `ProjectCopyTests`. - Crop, Vignette and text colour from Swift 1.2.5 (12.10c: `8b5a6af`, `5ece82b`, `0488cab`, `3317d02`, `4e5f4ec`). The crop ratios gain 3:4 and 9:16 (`cropRatio`, `CropControls`). Choosing the Crop tool with a selection that is not empty starts the frame at the outline's box (`boundingRect`, which leaves control points out as CoreGraphics' `boundingBoxOfPath` does; `controlPointRect` would not) rounded out to whole pixels (`integral`) and cut to the canvas, the canvas when that is no valid frame (an outline nudged wholly off it; an empty line between rows, which rounds to a row, stays out through `isEmpty`). Vignette opens round (`vignetteRoundness` 100, the default a number that is none falls back to). On an empty layer (no pixels, no adjustment, no folder: `canVignette`, the gate the Filter menu's entry reads) it begins from clear pixels the size of the layer's box, grows its grid to the canvas and frames the canvas (`FilterJob::canvas`, mapped into the image's pixels through the inverse mapping; top-left rows, so Swift's flip is gone), painting clear pixels too (`AdjustPixels.c`'s `fillsClear`, Swift's kernel as it stands); on a layer with pixels it frames them and recolours only them, as before. The commit lands on a layer still without pixels (`startedEmpty`), and the canvas draws an empty layer while a filter previews it. `canAdjustColors` and `canVignette` share `canAdjust(allowingEmpty)`; Swift's separate `adjustment == nil` and `isGroup` terms are the twin's argument and `canAdjust`'s own. The text colour previews while a picker is open: the Type bar's picker and the foreground picker opened over open text (`ColorPickerState::editedText`, the draft and its colour), through `previewTextColor`, which the Type bar calls on every change of the working colour (Swift's `onChange`), and only on the draft the picker opened for; Cancel puts the draft's colour back, OK keeps it and moves the foreground, and a Cancel with no text open leaves the next text's colour. Swift's `background: false` term in `previewTextColor` is dead (only the foreground picker records `editedText`) and left out. The tab strip's edge fade (`1fb6a3f`, `e8ed83b`) has no subject here: the twin's strip draws no fade, and it sits in a `QToolBar` under the desktop's own title bar, which drags the window itself. Tests: Swift's `textColorPickerPreviewsAndRestoresDraft` and `theForegroundPickerPreviewsOpenTextToo` in `TypeToolTests`, `thePickersColourPreviewsOnOpenText` in `TypeControlsTests`, `theFrameStartsAtTheSelection` and the ratios in `CropTests` and `CropControlsTests`, `VignetteCanvasTests` (the kernel's frame and fill, the gate and menu, an empty layer's commit and canvas preview, a layer with pixels), the defaults and the kernel's arguments in `FinishingFilterTests`. @@ -164,9 +164,12 @@ Platform adaptations, each forced by a missing Qt analog: - Snapping while drawing (13.5a, Swift 1.3.5: `d6e3e93`, `8b1369a`): `snappedPoint` and `snappedSelectionOffset` live in `Crop.cpp`, Swift's file. A drawn point moves each axis to the nearest crop target within reach (the first of the nearest, reach inclusive) and shows the lines met; a moved selection snaps its rounded box's edges or middle through `TransformSnap::offset`, an axis Shift holds left out; with snapping off, or no move begun, the input comes back and the lines go. The canvas's `snappedCorner` snaps a Marquee's start (a Lasso's never), a Marquee's dragged corner, a shape's start and dragged corner, ten view points' reach, and the selection drag; Ctrl, Swift's Control as the crop and the Move tool read it, draws and moves freely and clears the lines. Every release clears them, as Swift's `mouseUp`. Tests: Swift's `drawnPointsSnapToTheSnapToTargets` in `GuideTests`; a moved selection's offsets (rounding, a locked axis, a turned ellipse's own box) and the gestures at 100% and 200% in `SnapDrawingCanvasTests`. - Grid Settings (13.5b, Swift 1.3.5: `1c819d0`, `db5eafa`): `LayoutGrid` is Swift's struct: spacing 2–4096 and subdivisions 1–64, never finer than a pixel, clamped as it is made, plain members where Swift has `let`, so it stays assignable (as `CanvasSizeDraft`); its lines are counted from the origin, so an uneven step stays on the majors. `GridAppearance` holds Photoshop's presets by Swift's raw values (`allGridPresets`, `rawValue`, `presetColor`, none for Custom), the styles (`allGridStyles`, `dashes` in screen points), Custom's colour and the majors' opacity (1% at least when drawn; subdivisions at 28/45 of it). Both are the person's: `ToolDefaults` gains `integer`, `text` (empty when nothing is stored) and their `set`s, and the session reads `LayoutGrid::stored()` and `GridAppearance::stored()` as its members are made, under Swift's keys (`gridSpacing`, `gridSubdivisions`, `gridColor`, `gridCustomColor` as hex, `gridStyle`, `gridOpacity`); a stored value that cannot be read is warned of on `omaphoto.app` and left at its default, where Swift falls back in silence. `setLayoutGrid` and `setGridAppearance` store and notify, silent when unchanged. Snap To Grid, the guide snapping and the overlay read the session's grid; the overlay draws the majors in the chosen colour, opacity and dashes (counted in pen widths for Qt) and the dotted subdivisions in that colour, and `GuidesShown` carries both, so a change repaints. `ProjectController::gridSettings` lives in `ProjectController+Resize.cpp` beside the other sheets, since the controller's own file would pass 500 lines: refused without a window or while a modal sheet is up (Swift's `attachedSheet`), it shows the grid, opens `GridSettingsSheet` in the window-modal `sheet` titled Grid, previews every change through the session, and on its answer puts back the grid's visibility and either keeps the settings or restores them; nothing is undone or saved, and the project is not busy. `UI/GridSettingsSheet` twins Swift's: the title, Color (a preset menu and the `DialogColorSwatch` "Grid Color"), Style, Opacity (a half-point scrub, a slider and a 48-point field with "%", stepped by arrows, clamped), a rule, "Gridline every" with "pixels" (a point's scrub) and Subdivisions (a fifth of a point's), the note (the step to two places, grouped as Swift's `formatted()`, or the limits in `BrightText`), Cancel, Restore Defaults (Custom's colour kept) and the default OK, resting while invalid; 360 wide, 24 in, 18 apart. A field takes any whole number, as Swift's binding, so OK rests until the grid is valid, and previews only valid grids. A picked colour becomes Custom's, remembering the preset it left so picking that colour again returns to it; a preset chosen from the menu ends that. A fix beyond Swift: the picker reports 8-bit colours, so Swift's comparisons miss presets that are no byte (Light Gray's 0.7): opening the picker on Light Gray turns the grid Custom at once. The twin compares both sides in the picker's 8 bits, since its Cancel hands back the exact original, so opening and Cancel both keep a preset. Left out as dead: Swift's reset of the pick in its return branch (returning to a preset ends the pick through the preset's own change, in Swift too) and `ToolDefaults::text`'s fallback (Swift's callers pass ""). Untested: the destructor's `releaseFocus` and picker close (without them the swatch's hide writes into members already gone; no sanitizer runs). Tests: `GuideGridTests` (Swift's four grid tests, the settings kept), `GridSettingsSheetTests`, the drawing in `GuideCanvasTests`, the flow in `ProjectResizeTests`, the entry in `ViewMenuTests` and `CompositorMenusTests`. - Typing's undo and font previews (13.6, Swift 1.3.5: `ee68ba9`, `1bd1df4`). Swift's first commit lets the text view answer Undo and Redo itself, since the window's history took them; the twin's editor has answered them since 8.9b (the keys win `ShortcutOverride`, the menus reach the focused canvas's editor), so only Swift's case is added as a test: typing in a new box and Ctrl+Z through the real menus clears it in one step. The session's `previewFont`, `keepFontPreview` and `endFontPreview` twin Swift's: open text only, each face tried from the style as it was before the first (`m_fontPreviewOriginal`, Swift's `@ObservationIgnored` member) on the draft's selection, refused when invalid or unchanged, kept on a choice, put back on a close without one. `TypeFontPicker` draws each face's name in that face through `Qt::FontRole`, which the combo's menu delegate honours, at the menu's pixel size; a face that cannot draw the letters of its own name (`QRawFont`, the face itself and not fontconfig's fallbacks) keeps the menu's font, as Swift's symbol fonts do. Which faces can is worked out once on a worker when the first picker is made (Swift's `prepareStyledNames`), joined when the menu loads, a name still missing worked out then. The picker takes Swift's preview closure at construction: a highlighted face shows (the `(Multiple)` row does not), and every close posts the revert to the event loop, since Qt reports a choice after `hidePopup`; a choice keeps the preview first, which empties the original, so the revert finds nothing. Swift's `chose` flag does the same work twice and is left out as dead. Equivalent mutants: each preview built from the draft rather than the original (the selection holds still while the menu is open), `previewFont`'s validity term, Swift's too (`setFont` keeps a valid style valid), the worker's start-once guard and its join when the menu loads (without either the same answers are worked out again, costing only time), and the letters-only test of a face's name (every installed name in the dev image is letters and hyphens DejaVu covers); untested in the dev image: a face that cannot draw its own name (only DejaVu is installed). Tests: `TypeFontPreviewTests`, the Ctrl+Z case in `InlineTextDrawTests`. +- New Canvas presets and the Quick Look preview (13.7, Swift 1.3.6–1.3.7: `aaf3dc0`, `14aad18`, `4868465`): `NewCanvasSheet` twins `aaf3dc0`: the subtitle goes; beside "New canvas" a 28-point `QToolButton` (`presetSizes`, an instant popup) draws Swift's three 2.5-point dots in `WindowText`, flush with the fields' right edge, its own `paintEvent` drawing no menu arrow; Swift's tooltip and the accessible name "Preset sizes". `CanvasPreset` (in `NewCanvasSheet.h`, as Swift's file holds it) keeps Swift's `groups` and `all`; the menu is Custom, then each group after a separator, checkable `QAction`s in an exclusive `QActionGroup`, each holding its index in `all` (Custom −1). `checkPreset` checks the first preset whose sizes print as the fields' exact text, else Custom, on every edit of either field, as Swift's binding reads it; choosing a preset fills both fields; Custom fills nothing and checks the match again, since Qt checks a triggered action where Swift's picker sets nil and re-reads. Known difference: Swift's label is clickable ten points past the dots into the padding; the twin's button ends at the content's edge. The Quick Look preview twins `14aad18` and `4868465`: `ImageExporter::quickLookImages` returns nothing past 50 megapixels (logged as info), else renders the snapshot and `scaledJPEG`s it: the long side at most 1024, sides rounded, a pixel at least, `QImage::scaled` with `SmoothTransformation` (area averaging when shrinking) for CoreGraphics' high quality, drawn on white into `RGB888`, JPEG quality 80, 72 dpi as `CGImageDestination` writes; a render or scale that fails logs a warning on `compositor.io` and saves without one, Swift's `try?`. `QuickLookImages` (Swift's, in `ImageExporter.swift`) is declared in `ProjectStore.h`, since the exporter includes the store. `ProjectStore::save` takes it optionally: with one, the staged folder gains `QuickLook/Preview.jpg`, written and synced with its folder before the project's folder is synced and swapped in; without one the swapped-in project has none. Loading reads only the manifest and `images/`, so the folder is ignored, and `ProjectDigest` reads neither, so writing or deleting it is no external change. The controller's save worker makes the preview and hands it to the store. `docs/writing-comp-files.md` gains Swift's line, OmaPhoto's name. Equivalent mutant: the heading's stretch (the title label takes the room). Untested: the null checks after scaling and allocating the flattened image (an allocation failure); without them a null scale would save a white preview. Tests: `NewCanvasSheetTests` (the dots filled and never outlined, `presetsAreSwiftsGroups`, `aPresetFillsTheFieldsAndTheMatchIsChecked`, `theMoreButtonDrawsThreeDotsAndOpensTheMenu`), `QuickLookPreviewTests` (the store's folder and its absence, load ignoring it, the JPEG byte for byte against smooth scaling on white at quality 80, sizes and rounding, the 50-megapixel bound, a failed render, a controller save), and the sync order in `ProjectReplaceTests`. +- Tabs reorder and overflow (13.14, Swift `1faf7a0`): `UI/ProjectTabLayout.h/.cpp` twin Swift's file: `projectTabSpacing`, `ProjectTabSlot`, `ProjectTabPillSlot`, `ProjectTabOverflow` (`contentWidth()` for Swift's computed property), `projectTabOverflowLabel` and `projectTabOverflow`, widths a `std::map` read through `at` (every tab has one), the pill's width a `std::function`. Left out as dead: `span`'s empty guard (never asked of no tabs) and `contentWidth`'s pill fallback (a pill always has a tab after it). `ProjectWorkspace::moveTab` is Swift's, clamped, and emits `changed()` only when a tab moved. `ProjectTabStrip` is now a plain `QWidget` that takes the toolbar's free width (the spacer after it went; its size hint is every tab in a row, its minimum none) and places its children by hand from `projectTabOverflow` over its own width: the overflow pill at x 0 and the visible tabs at y 3, 28 high, each as wide as its button's size hint (so the unsaved dot's extra width counts, as before), hidden tabs hidden; it lays out again on `resizeEvent`, on the workspace's signal and on each tab's session signal (a title or a dot changes widths; a hidden button posts no layout request). The QScrollArea, its offset and `showFront` are gone, as Swift's scroller and reveal are; the selected tab is always shown by the pinning. `OverflowTabsPill` (a `QToolButton`, `projectTabsOverflow`) paints an inactive tab's capsule, the label "N more tabs" at 12 pixels medium and a drawn `chevron.down`, the label and chevron at half opacity while the workspace cannot switch (the capsule stays, as Swift's `.opacity` sits before its background); tooltip and accessible name are the label; its width is Swift's `projectTabOverflowPillWidth` (the label's ceiling plus 11, 4, 10, 11). Its press opens a `QMenu` (`projectTabsOverflowMenu`, deleted on close) four points below it, as `popUp(positioning:)` does, listing the hidden tabs in order, "• " before an unsaved one, a choice selecting; while the workspace cannot switch no menu opens. The reorder twins Swift's: `ProjectTabButton` watches its title button's left press, move and release in global coordinates (Swift's `.global` space) and reports `TabDragPhase::changed` once the press has moved three points and `ended` on the release; the strip's `Reorder` is `TabReorderState` (others, widths, compacted x, start, origin, translation, target), `nearestSlot`, `renderX`, `makeReorder` and `commitReorder` as Swift's (a neighbour closed mid-drag has no place, as Swift's `if let`s). A drag begins only while the workspace can switch and across at least three points, selects its tab first and raises it over the others (Swift's `zIndex`); it commits on release only if the workspace can still switch; the others ease to their places through a 150 ms `QPropertyAnimation` of `pos` (`OutQuad` for SwiftUI's `easeOut`), the dragged tab following the pointer at once, and every tab eases home when it ends. Qt can lose a release: a drag whose tab closes is dropped on the workspace's signal (Swift's timer drops it too), and a drag on another tab drops a stale one first (Swift's timer would have cleared it within 0.1 s). Known difference: a lost release on the same tab leaves its stale state for that tab's next drag, which then measures from the old origin. Known difference: Swift's empty title bar beside the tabs drags the window (`TitleBarDragView`); Qt's toolbar is no title bar, so the strip's empty part does nothing. `NewTabDropSlot` has no twin, as before. Equivalent mutant: reporting `ended` after a click that never dragged (Swift's gesture never ends one); it differs only after a lost release, where it would drop another tab's stale drag a click sooner. Tests: `ProjectTabLayoutTests` (Swift's six, the bounds at exactly fitting, a lone wide pinned tab, the pill's width asked by count), `ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments` (Swift's, plus its signal), `ProjectTabDragTests` (following the pointer, clamping, the gap, the slide's duration, curve and end, stacking, landing left and right and in place, a small or upward move as a click, a busy project at the press and at the drop, a tab closed mid-drag, a lost release giving way to the next drag, a three-point move and a move under another button, the overflow pill's label, place and width, the menu's entries, place and choice, pinning, the busy pill), and `ProjectWorkspaceViewTests` (overflow in the window; the strip ends before the zoom buttons). `ProjectTabStripTests::onlyANewFrontTabScrollsIntoView` went with the scrolling. +- Ungroup Layers and the mask pointer (13.15, Swift `1318f1e`, `6c3b9a5`, `6955a6f`): `EditorSession::canUngroupLayers` and `ungroupLayers` sit in `LayerGroups.cpp` (declared in `LayerGroups+Session.h`), as Swift's extension: the folder's direct children, their parent now the folder's own, take its place in the list in their order; `releaseDetachedClipping` then frees clips that no longer sit on their base; `finishOpacityEdit` ends a drag first; one `Ungroup Layers` step selects the children with the lowest active (none for an empty folder) and forgets the folder's collapse. Swift's `LayerHierarchy.validate` guard is dead and left out: unwrapping a folder only shortens parent chains, so nothing it refuses can appear; its `document` guard is `canEditLayers`'. The Layer menu gains Ungroup Layers (Ctrl+Shift+G) after Group Selected Layers, enabled by `canUngroupLayers`, and `ShortcutDefinition::all` Swift's "Ungroup Layers" entry (g, Cmd+Shift), so it can be remapped; the list's context menu shows it after Group Selected Layers for a right-clicked folder alone (Swift reads the clicked row, not the active layer), enabled by `canUngroupLayers`. `NativeLayerList::showMaskCursor` twins Swift's `showMaskCursor`: the duplicate pointer drawn over an eye in a 7.5 by 5.5 box at the hot spot plus (15, 18), haloed by sixteen white copies one point out, the image widened to hold it (29 by 32 at 1×), the hot spot the pointer's; Swift's `eye.fill` is drawn as paths (an almond of two quadratic curves, a ring cut round a filled pupil). Over a mask thumbnail with Alt, `cursorFor` shows it whatever `canEditLayers` says, as Swift's now does (13.12 brings the Alt-click that shows the mask alone). Equivalent mutant: the context entry enabled by `canEditLayers` (the right-click makes the folder active, so `canUngroupLayers` says the same). Tests: `GroupSessionTests` (Swift's three ungroup tests; collapse forgotten, signals, a plain layer refused silently, a nested and an empty folder, an opacity drag ended, busy), `LayerContextMenuTests::aFoldersMenuOffersUngroup`, the menu entry and its gate in `CompositorMenusTests`, the shortcut count in `KeyboardShortcutsTests`, the eye pointer's pixels and hot spot in `LayerDragGestureTests`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. -- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. +- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. - `CompositorMenus` builds the window's `QMenuBar` from Swift's `.commands`, entry for entry, as far as the session functions exist (`TASKS.md`, "Menu items owed", lists the rest); ⌘ is Ctrl and ⌥ is Alt in every shortcut. Its entries follow the front tab's session, like the toolbar. Swift's Undo and Redo keep a text field's own undo while one has focus, so the twin's do the same: with a `QLineEdit` in focus the entries are the field's, titled bare and always enabled; otherwise they carry the step's name, and only while the session's history is usable. Focus is state the session does not announce, so the menus also sync on `QApplication::focusChanged`. Keyboard navigation (Alt+E) hands focus to the menu bar itself, so the menus keep the field that held it until focus lands anywhere else. The View menu's Pixel Grid entry is checkable and follows `showsPixelGrid`. `Close Project` closes the tab (Swift's `close(window)` hands over to the workspace); `Delete Layer` is Swift's `deleteLayerOrMask`, which takes a chosen effect first since 9.6a; since 9.6d its title names that effect (`Delete Stroke`). The `Hide` entries and `Check for Updates` have no twin (the desktop hides windows; distro packages update). - The session's gates (`canEditLayers`, `canUseHistory`) and its `selectLayer`, `selectTool`, `undo` and `restore` read only the state that exists so far. `TASKS.md` lists, under "Session hooks owed", the term each later item adds. `reorderLayers` takes a `std::set<int>` for `IndexSet` and reproduces SwiftUI's `move(fromOffsets:toOffset:)`. - `LayerMask::clipImage` returns nil when the placed grid cannot be rendered, as Swift's `try?` does, and logs a warning on `omaphoto.rendering` so the loss is visible. diff --git a/TASKS.md b/TASKS.md index 6820881..108801e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -431,7 +431,8 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - 13.5b review (Fable 5.1): round 1 FAIL (the picker's Cancel turned Light Gray, Light Blue and Green into Custom, since only one side was compared in 8 bits; the drawing's dots, dashes, caps and 4-point threshold loosely tested); round 2 PASS. The CPU cap was lifted to all 32 cores during this item (scripts/mutate.py follows). - [x] 13.6 Part five: Ctrl+Z while typing takes back what was typed (`ee68ba9`); font previews in the Type bar (`1bd1df4`). - 13.6 review (Fable 5.1): round 1 FAIL (four survivors unrecorded; the code judged right against Swift); round 2 PASS. -- [ ] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone. +- [x] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone. +- 13.7 port (agent): tests in `NewCanvasSheetTests` (presets, the check, the dots), `QuickLookPreviewTests` (the store's folder, load ignoring it, the JPEG byte for byte, sizes, the 50-megapixel bound, a failed render, a controller save) and the sync order in `ProjectReplaceTests`. Survivors recorded: the heading's stretch (equivalent); the scale and allocation null checks untested. No review yet. - [ ] 13.8 Version 1.4, part one: the changes behind the GPU canvas that the CPU canvas and export share: a sharper canvas with adjustment layers (adjustment surfaces at device resolution, adjustment operations scaled with the surface, direct sampling for an upright layer at 1:1, tiled layers included) and the PSD adjustment and mask fixes (`8c0417b`); Photoshop's positive Saturation (`fe7a83d`); Levels and Hue/Saturation on all cores (`af3b568`); a blur's preview kept while the layer grows (`3ccc125`); the hierarchy kept until what shapes it changes (`d0135c8`, `dcf1c66`). - [ ] 13.9a Part two: blending. Clipping stacks and live-mask coverage at device resolution; an adjustment in any mode over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`6dc2219`); a clipping stack composited in its base's mode in every mode, on the canvas, in export and in Copy Merged (`8ab32c9`; Qt keeps the stack's mode: a held judgment to prove); Soft Light by Photoshop's formula as Core Image computes it, on the canvas, in export and in Copy Merged (`5a8f6ce`: a 0.9 source over a 0.5 backdrop gives about 170; compare Qt's formula first); soft, partly clear painted layers blending alike in Soft Light, Overlay, Multiply and Color Dodge (`e6c964b`, `softEdgedLayersBlendTheSame`). - [ ] 13.9b Part three: the Metal canvas (`cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`) stays CPU (the port's decision); each contract its tests hold for every canvas is judged and, where the twin lacks it, ported: Grain and Add Noise anchored to the document while panning and zooming, the canvas mapping its surface back to document coordinates (`54dd707`; the twin's adjustments take view-space regions today); every adjustment kind with alpha, own and folder masks and opacity, blurs at the drawn scale and direction (`54dd707`); a moved or duplicated selection's preview and commit (the hole cut only for a move, the rounded offset, opacity and coverage once) and the lazy tiles realised before display and commit, a failure cancelling the move with `brushError` (`4b04323`); live paint, erase and mask paint replacing patch interiors, revealing paint past the old bounds under the old mask, gradients' selection clip and opacity matching the commit, the lazy gradient fill realised before display and commit, Smudge and Liquify previews keeping mask, opacity and placement (`5708ed3`, `91cb0b1`); a new one-pixel white mask revealing its whole layer, rotated or scaled without a faded edge, and the live paint grid's extent kept (`93de9e7`); rotation, flips and scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom (`cdf8674`); non-adjacent live mask sources, distortion previews (perspective, folded, flipped, placed masks, effects), text previews keeping effects and handing them on without a flash, new drafts above the active layer inside a clipping stack, lines above the image, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`f773f7b`); sharp reductions and fresh paint pixels (`4c5e1a6`, `d04f159`); `b1b7490` only words a test's diagnostics. Also named, each to judge: sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on ordinary pixel layers, and filter, Levels and Hue/Saturation previews replacing the layer's pixels with its alpha, mask, opacity and placement (`cdf8674`); image-edge sampling that carries edge values outward for filtering and keeps the image's own boundary, for ordinary images and live grids as for the white mask (`93de9e7`); adjustment results equal to the CPU reference, deterministic Noise and Grain seeds, Add Noise counted in frame pixels from the document's corner and Grain's size in document pixels (`54dd707`); a lazy gradient fill that fails cancelling the gradient with `brushError`, and a failed commit publishing it (`5708ed3`); Smudge and Liquify strokes and commits equal to the CPU algorithms (`91cb0b1`, `matchesTheCPU`: the twin's CPU path is the reference, a held judgment); new text shown when the active layer cannot draw, the pixel grid and the new text frame drawn above the image and passing input through, and edits keeping mask gray values and effects and placed masks as paint bounds grow (`f773f7b`). Texture retention, eviction and blits are GPU mechanisms with no CPU twin. @@ -439,8 +440,10 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - [ ] 13.11 Version 1.4.1, part one: why a brush can't paint, Select All then Inverse deselects (`133c34a`); painting at the layer's own resolution (`d3170ab`): Clone Stamp and Blur sample there, the Brush and eraser on a scaled-down, rotated or unequally scaled layer that holds pixels lay a tip of the layer's own pixels and show a smooth edge scaled back up, while an empty layer, however small its transform, paints at document resolution (`paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution`); the twin's coverage in the stroke's grid (`BrushStroke::grid`, `BrushCoverage::render`) may already hold both, a held judgment to prove with `NativeResolutionPaintTests`' cases; Cancel and Apply only when an edit waits, scaled layers' scale (`67cc31e`). - [ ] 13.12 Part two: masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (`b3419ab`). - [ ] 13.13 Part three: Ctrl flips Auto Select both ways and Shift the aspect lock, live in the bar (`bca8f13`, `03d692c`); Move bar values apply without Apply as one step (`686d8c7`); resize handles snap (`c459f88`). -- [ ] 13.14 Part four: tabs drag to reorder, the ones that don't fit gather in a menu (`1faf7a0`). -- [ ] 13.15 Part five: Layer › Ungroup Layers (Ctrl+Shift+G) and in a folder's context menu (`1318f1e`, `6c3b9a5`); Alt over a mask thumbnail shows the duplicate pointer with an eye (`6955a6f`). +- [x] 13.14 Part four: tabs drag to reorder, the ones that don't fit gather in a menu (`1faf7a0`). +- 13.14 port (agent): tests in `ProjectTabLayoutTests` (Swift's six and the bounds), `ProjectTabDragTests` (drag, slide, stacking, landing, threshold, busy, lost release, a close mid-drag, the overflow pill and its menu), `ProjectWorkspaceTests` (`moveTab`) and `ProjectWorkspaceViewTests`. Survivor recorded: reporting `ended` after a click that never dragged (equivalent but after a lost release); `span`'s empty guard and `contentWidth`'s pill fallback removed as dead. No review yet. +- [x] 13.15 Part five: Layer › Ungroup Layers (Ctrl+Shift+G) and in a folder's context menu (`1318f1e`, `6c3b9a5`); Alt over a mask thumbnail shows the duplicate pointer with an eye (`6955a6f`). +- 13.15 port (agent): tests in `GroupSessionTests` (Swift's three and more), `LayerContextMenuTests`, `CompositorMenusTests`, `KeyboardShortcutsTests` and `LayerDragGestureTests` (the eye pointer). Survivor recorded: the context entry gated by `canEditLayers` (equivalent: the right-click makes the folder active); Swift's validation guard left out as dead. No review yet. - [ ] 13.16 Versions 1.4.2–1.4.3: Liquify keeps pixels sharp (`7164dd4`), Smudge without ghost copies (`ad9ad7c`), the Blur brush's own Radius (`504129d`), Blur, Smudge and Liquify on big canvases (`095da2f`). - [ ] 13.17 Version 1.4.4: quit works mid-gradient or with a dialog open (`1aad384`); Dither › Scanlines (CRT) on all cores (`9c99853`, `cd2f998`). - [ ] 13.18 Version 1.4.5: Camera Raw curves as Photoshop's (`60d9117`): the smooth parametric curve and the per-channel point curves, Refine Saturation's new meaning (0 the RGB curve, −100 brightness alone, above 0 stronger colour) and its words; the graph: a press picks a point, divider or tonal region once and keeps it through the drag, a region drag lifts or lowers that region, dividers move in the bottom strip and stay ordered, point mode picks by distance, adds and drags a new point at once, keeps inner points ordered and moves the end points' outputs alone, the chosen point lit and the readout following it, a new channel or preset clearing the choice. From cb85b86d589a862422b464dc94e4fe78b5eaeacb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= <zhangyanghaha0407@outlook.com> Date: Thu, 1 Oct 2026 17:47:51 -0400 Subject: [PATCH 10/17] Sharper canvas, PSD adjustments and masks, Photoshop's Saturation, Levels and Hue/Saturation on every core, a growing blur's preview kept, the hierarchy kept (13.8) --- CMakeLists.txt | 1 + kernels/LevelsPixels.c | 32 +++++ kernels/LevelsPixels.h | 1 + src/Document/BrushStroke.cpp | 32 +++++ src/Document/BrushStroke.h | 4 + src/Document/EditorSession+Jobs.h | 4 +- src/Document/EditorSession+Model.h | 3 +- src/Document/EditorSession+Transform.cpp | 4 +- src/Document/Filters+Session.cpp | 28 ++-- src/Document/Filters.cpp | 1 - src/Document/Filters.h | 6 +- src/Document/HueSaturation+Filter.cpp | 121 +++++++++--------- src/Document/HueSaturation.h | 2 + src/Document/LayerAdjustment.cpp | 3 +- src/Document/LayerGroups+Order.cpp | 108 ++++++++++++++++ src/Document/LayerGroups.cpp | 55 +------- src/Document/LayerGroups.h | 23 ++++ src/Document/LayerMask.cpp | 2 +- src/Document/Levels.cpp | 8 +- src/IO/PSD/PSDDocumentBuilder.cpp | 34 ++++- src/IO/PSD/PSDDocumentBuilder.h | 2 + src/IO/PSD/PSDReader+Adjustments.cpp | 36 ++++-- src/IO/PSD/PSDReader+Records.cpp | 3 +- src/IO/PSD/PSDReader+Records.h | 2 + src/IO/PSD/PSDReader.cpp | 2 + src/IO/PSD/PSDReader.h | 4 + src/IO/PSD/PSDTypes.h | 3 + src/Rendering/AdjustmentSurface.cpp | 15 ++- src/Rendering/LayerRenderer.cpp | 6 +- src/Rendering/LayerRenderer.h | 3 +- src/Rendering/TiledLayerRenderer.cpp | 3 +- tests/AdjustmentBandsTests.cpp | 124 ++++++++++++++++++ tests/AdjustmentSurfaceTests.cpp | 23 +++- tests/BlurNoiseAdjustmentTests.cpp | 8 ++ tests/FilterSessionTests.cpp | 3 +- tests/FilterTests.cpp | 52 ++++++++ tests/FinishingFilterTests.cpp | 28 ++-- tests/GroupTests.cpp | 4 +- tests/HueSaturationFailureTests.cpp | 4 +- tests/HueSaturationTests.cpp | 19 +++ tests/LayerOrderTests.cpp | 143 +++++++++++++++++++++ tests/PSDAdjustmentTests.cpp | 155 +++++++++++++++++++++++ tests/PSDReaderFixtures.h | 3 +- tests/PSDReaderTests.cpp | 23 +++- tests/SharpCanvasTests.cpp | 81 ++++++++++++ tests/Tests.cmake | 6 + 46 files changed, 1041 insertions(+), 186 deletions(-) create mode 100644 src/Document/LayerGroups+Order.cpp create mode 100644 tests/AdjustmentBandsTests.cpp create mode 100644 tests/LayerOrderTests.cpp create mode 100644 tests/PSDAdjustmentTests.cpp create mode 100644 tests/SharpCanvasTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8983403..51f9c2b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,7 @@ add_library(compositor_core STATIC src/Document/AdjustmentEditing.cpp src/Document/LayerAppearance.cpp src/Document/LayerGroups.cpp + src/Document/LayerGroups+Order.cpp src/Document/LayerMask.cpp src/Document/LayerTransform.cpp src/Document/LiveLayerMask.cpp diff --git a/kernels/LevelsPixels.c b/kernels/LevelsPixels.c index a744088..fa32db1 100644 --- a/kernels/LevelsPixels.c +++ b/kernels/LevelsPixels.c @@ -26,3 +26,35 @@ void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t cou } } } +/// A color lookup through `cube` (`dimension`³ RGBA entries, red varying fastest), blended between the eight nearest +/// entries, on unpremultiplied colors; alpha is kept. +void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension) { + const float scale = (dimension - 1) / 255.0f; + const size_t dy = dimension, dz = (size_t)dimension * dimension; + for (size_t i=0; i<count; ++i) { + uint8_t *p = pixels + i*4; + float alpha = p[3]; + if (!alpha) continue; + float position[3], fraction[3]; + int lo[3]; + for (int channel=0; channel<3; ++channel) { + position[channel] = fminf(255, p[channel]*255.0f/alpha) * scale; + lo[channel] = (int)position[channel]; + if (lo[channel] > dimension - 2) lo[channel] = dimension - 2; + fraction[channel] = position[channel] - lo[channel]; + } + const float *base = cube + ((size_t)lo[0] + lo[1]*dy + lo[2]*dz) * 4; + const size_t sx = 4, sy = dy*4, sz = dz*4; + for (int channel=0; channel<3; ++channel) { + const float *c = base + channel; + float x00 = c[0] + (c[sx] - c[0]) * fraction[0]; + float x10 = c[sy] + (c[sy+sx] - c[sy]) * fraction[0]; + float x01 = c[sz] + (c[sz+sx] - c[sz]) * fraction[0]; + float x11 = c[sz+sy] + (c[sz+sy+sx] - c[sz+sy]) * fraction[0]; + float y0 = x00 + (x10 - x00) * fraction[1]; + float y1 = x01 + (x11 - x01) * fraction[1]; + float result = y0 + (y1 - y0) * fraction[2]; + p[channel] = (uint8_t)fminf(alpha, fmaxf(0, roundf(result*alpha))); + } + } +} diff --git a/kernels/LevelsPixels.h b/kernels/LevelsPixels.h index 2fbb385..3281d6f 100644 --- a/kernels/LevelsPixels.h +++ b/kernels/LevelsPixels.h @@ -2,3 +2,4 @@ #include <stddef.h> void levels_apply(uint8_t *pixels, size_t count, const float *tables); void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t count, double *bins); +void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension); diff --git a/src/Document/BrushStroke.cpp b/src/Document/BrushStroke.cpp index a5f3d44..ee2bb25 100644 --- a/src/Document/BrushStroke.cpp +++ b/src/Document/BrushStroke.cpp @@ -3,6 +3,7 @@ #include "IO/ImageExporter.h" #include <QtConcurrent> #include <cmath> +#include <cstring> #include <numeric> #include <stdexcept> @@ -28,6 +29,37 @@ QImage BrushRaster::context(int width, int height, bool mask) return surface; } +QImage BrushRaster::copy(const QImage &image) +{ + QImage result = context(image.width(), image.height(), false); + if (image.format() != QImage::Format_RGBA8888_Premultiplied) { + QPainter painter(&result); + draw(image, QRectF(0, 0, image.width(), image.height()), painter); + return result; + } + for (int y = 0; y < image.height(); ++y) + std::memcpy(result.scanLine(y), image.constScanLine(y), size_t(image.width()) * 4); + return result; +} + +void BrushRaster::inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body) +{ + const int bands = count < 250'000 ? 1 : QThread::idealThreadCount() * 2; + const qsizetype size = (count + bands - 1) / bands; + try { + std::vector<int> indices(static_cast<size_t>(bands)); + std::iota(indices.begin(), indices.end(), 0); + PoolMap::blocking(indices, [&](int band) { + const qsizetype start = band * size; + if (start < count) + body(start, std::min(size, count - start)); + }); + } catch (const std::bad_alloc &) { + // Out of memory fails as a context would. + throw ExportError(ExportError::Kind::render); + } +} + void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source) { context.save(); diff --git a/src/Document/BrushStroke.h b/src/Document/BrushStroke.h index 8c82c01..d5d96a2 100644 --- a/src/Document/BrushStroke.h +++ b/src/Document/BrushStroke.h @@ -44,6 +44,10 @@ struct BrushSettings { // Shared raster drawing; coverage is copied without color conversion. namespace BrushRaster { QImage context(int width, int height, bool mask); +// A colour context holding `image`; its own layout copies bytewise. +QImage copy(const QImage &image); +// Runs `body` over `count` pixels, band by band: start, length. +void inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body); // Copies `image`, or its `source` part, into `rect`. void draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source = QRectF()); // A solid colour through gray coverage, and a clip, 1:1. diff --git a/src/Document/EditorSession+Jobs.h b/src/Document/EditorSession+Jobs.h index 2fd3b9e..9c3ec95 100644 --- a/src/Document/EditorSession+Jobs.h +++ b/src/Document/EditorSession+Jobs.h @@ -104,8 +104,8 @@ struct SessionJobs { FilterSettings settings; // Camera Raw's scope of the grade itself. std::optional<CameraRawScope> scope = std::nullopt; - // Its grid's version: a newer grid drops it. - quint64 sourceVersion = 0; + // The grown layer it was made from: it shows there. + std::optional<LayerTransform> placement = std::nullopt; }; struct FilterMade { std::optional<ImportedImage> asset; diff --git a/src/Document/EditorSession+Model.h b/src/Document/EditorSession+Model.h index d41f704..ed48f36 100644 --- a/src/Document/EditorSession+Model.h +++ b/src/Document/EditorSession+Model.h @@ -75,7 +75,8 @@ struct CanvasDocument { QSizeF size() const { return QSizeF(width, height); } static std::optional<int> validDimension(const QString &value); - std::vector<LayerHierarchy::Entry> hierarchyEntries() const; + // Drawing order and what shows (LayerOrder), kept between asks. + LayerOrder::Result hierarchy() const; QSet<QUuid> effectiveVisibleIDs() const; QHash<QUuid, double> effectiveOpacities() const; std::vector<ImageLayer> renderLayers() const; diff --git a/src/Document/EditorSession+Transform.cpp b/src/Document/EditorSession+Transform.cpp index 5f55454..c4b84e2 100644 --- a/src/Document/EditorSession+Transform.cpp +++ b/src/Document/EditorSession+Transform.cpp @@ -257,8 +257,8 @@ LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const if (const std::optional<LayerTransform> pending = pendingTransform(layer)) return *pending; // Content-Aware Fill past the edge previews on the grown layer. - if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id)) - return *m_filterEdit->grownTransform; + if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id)) + return *m_filterEdit->preparedTransform; return layer.transform; } diff --git a/src/Document/Filters+Session.cpp b/src/Document/Filters+Session.cpp index fe4dbe9..ad99f41 100644 --- a/src/Document/Filters+Session.cpp +++ b/src/Document/Filters+Session.cpp @@ -80,10 +80,6 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview) if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) { try { edit.growForBlur(); - edit.preparedPreview.reset(); - // A waiting job moves to the grid its version names. - if (edit.pending) - edit.pending = edit.previewJob(); } catch (const ProjectError &error) { setBrushError(QString::fromUtf8(error.what())); } catch (const ExportError &error) { @@ -111,6 +107,7 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview) return; } edit.pending = edit.previewJob(); + edit.pendingTransform = edit.grownTransform; renderFilterPreview(); } @@ -127,22 +124,22 @@ void EditorSession::renderFilterPreview() const FilterJob job = *std::exchange(edit.pending, std::nullopt); m_filterPreviewFor = edit.id; m_filterRendering = true; - m_filterPreview.setFuture(QtConcurrent::run([job, version = edit.previewSourceVersion]() -> Filtered { + m_filterPreview.setFuture(QtConcurrent::run([job, placement = edit.pendingTransform]() -> Filtered { try { if (job.kind == FilterKind::cameraRaw) { auto [image, scope] = CameraRawScope::preview(job); - return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), version}; + return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), placement}; } - return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, version}; + return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, placement}; } catch (const ContentFillError &error) { - return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version}; + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement}; } catch (const SubjectRemovalError &error) { - return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version}; + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement}; } catch (const ExportError &error) { - return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version}; + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement}; } catch (const ProjectError &error) { // Settings past their bounds: Black & White, Color Balance. - return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version}; + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement}; } })); } @@ -153,18 +150,17 @@ void EditorSession::finishFilterPreview() { m_filterRendering = false; const Filtered result = m_filterPreview.result(); - // A cancelled edit takes nothing; an older grid renders again. - if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor && result.sourceVersion != m_filterEdit->previewSourceVersion) { - m_filterEdit->preparing = false; - notify(); - } else if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) { + // A cancelled edit takes nothing. + if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) { FilterEdit &edit = *m_filterEdit; edit.preparing = false; edit.previewError = result.failure; if (result.scope) edit.rawPanel.scope = result.scope; + // Made before the layer grew, it shows there till replaced. if (edit.preview || isAutomatic(edit.kind)) { edit.preparedPreview = result.image; + edit.preparedTransform = result.placement; edit.preparedSettings = result.settings; ++m_brushRevision; } diff --git a/src/Document/Filters.cpp b/src/Document/Filters.cpp index a1330be..7a9e6c7 100644 --- a/src/Document/Filters.cpp +++ b/src/Document/Filters.cpp @@ -272,7 +272,6 @@ void FilterEdit::grow(const QRectF &extent) void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed) { const QSize size = source.size(); - ++previewSourceVersion; mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height()); // Noise, grain and dither preview whole: enlarged, they look coarse. const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::contentAwareFill diff --git a/src/Document/Filters.h b/src/Document/Filters.h index c301f4d..fc0a741 100644 --- a/src/Document/Filters.h +++ b/src/Document/Filters.h @@ -171,10 +171,12 @@ public: // Add Noise's grain, fixed while the panel is open. const quint32 seed = QRandomGenerator::global()->generate(); std::optional<QImage> preparedPreview; + // Where the prepared preview sits; none: the layer's own place. + std::optional<LayerTransform> preparedTransform; + // The grown layer `pending` is made from. + std::optional<LayerTransform> pendingTransform; // The settings the prepared preview was made with. std::optional<FilterSettings> preparedSettings; - // Moves with each new grid: an older render lands nothing. - quint64 previewSourceVersion = 0; std::optional<FilterJob> pending; CameraRawPanel rawPanel; // Vignette on an empty layer: the canvas it fills. diff --git a/src/Document/HueSaturation+Filter.cpp b/src/Document/HueSaturation+Filter.cpp index 8e357f8..8da2e01 100644 --- a/src/Document/HueSaturation+Filter.cpp +++ b/src/Document/HueSaturation+Filter.cpp @@ -1,13 +1,14 @@ #include "Document/BrushStroke.h" -#include "Rendering/PoolMap.h" #include "Document/HueSaturation.h" #include "Document/PixelAdjust.h" #include "IO/ImageExporter.h" -#include <QPainter> -#include <QtConcurrent> #include <algorithm> #include <cmath> -#include <numeric> +#include <deque> +#include <mutex> +extern "C" { +#include "LevelsPixels.h" +} namespace { struct HSL { @@ -48,38 +49,38 @@ HueSaturationFilter::Color toRGB(double hue, double saturation, double lightness return {clamp(color.red), clamp(color.green), clamp(color.blue)}; } -// Core Image's colour cube: unpremultiplied, trilinear, premultiplied back. -void applyCube(uchar *bits, qsizetype stride, int width, const std::vector<float> &cube, const std::vector<int> &rows) +// The last tables built: building costs more than applying. +struct CubeCache { + std::mutex lock; + std::deque<std::pair<HueSaturationSettings, std::vector<float>>> cubes; +}; + +CubeCache &cubeCache() { - constexpr int size = HueSaturationFilter::dimension, last = size - 1; - PoolMap::blocking(rows, [&](int y) { - uchar *pixel = bits + y * stride; - for (int x = 0; x < width; ++x, pixel += 4) { - const float alpha = pixel[3]; - if (alpha == 0) { - std::fill_n(pixel, 3, uchar(0)); - continue; + static CubeCache cache; + return cache; +} + +std::vector<float> built(const HueSaturationSettings &settings) +{ + const std::vector<HueSaturationFilter::HueResponse> response = HueSaturationFilter::hueResponse(settings); + constexpr int dimension = HueSaturationFilter::dimension; + std::vector<float> values(size_t(dimension * dimension * dimension * 4)); + size_t index = 0; + const double step = dimension - 1; + for (int blue = 0; blue < dimension; ++blue) { + for (int green = 0; green < dimension; ++green) { + for (int red = 0; red < dimension; ++red) { + const HueSaturationFilter::Color color = HueSaturationFilter::adjust(red / step, green / step, blue / step, settings, response); + values[index] = float(color.red); + values[index + 1] = float(color.green); + values[index + 2] = float(color.blue); + values[index + 3] = 1; + index += 4; } - float place[3], fraction[3]; - int low[3]; - for (int channel = 0; channel < 3; ++channel) { - place[channel] = std::min(1.0f, pixel[channel] / alpha) * last; - low[channel] = std::min(int(place[channel]), last - 1); - fraction[channel] = place[channel] - float(low[channel]); - } - float out[3] = {0, 0, 0}; - for (int corner = 0; corner < 8; ++corner) { - const int r = low[0] + (corner & 1), g = low[1] + (corner >> 1 & 1), b = low[2] + (corner >> 2 & 1); - const float share = ((corner & 1) ? fraction[0] : 1 - fraction[0]) * ((corner >> 1 & 1) ? fraction[1] : 1 - fraction[1]) - * ((corner >> 2 & 1) ? fraction[2] : 1 - fraction[2]); - const float *entry = &cube[size_t(((b * size + g) * size + r) * 4)]; - for (int channel = 0; channel < 3; ++channel) - out[channel] += share * entry[channel]; - } - for (int channel = 0; channel < 3; ++channel) - pixel[channel] = uchar(std::lround(std::min(1.0f, std::max(0.0f, out[channel])) * alpha)); } - }); + } + return values; } } @@ -104,22 +105,20 @@ std::vector<HueSaturationFilter::HueResponse> HueSaturationFilter::hueResponse(c std::vector<float> HueSaturationFilter::cube(const HueSaturationSettings &settings) { - const std::vector<HueResponse> response = hueResponse(settings); - std::vector<float> values(size_t(dimension * dimension * dimension * 4)); - size_t index = 0; - const double step = dimension - 1; - for (int blue = 0; blue < dimension; ++blue) { - for (int green = 0; green < dimension; ++green) { - for (int red = 0; red < dimension; ++red) { - const Color color = adjust(red / step, green / step, blue / step, settings, response); - values[index] = float(color.red); - values[index + 1] = float(color.green); - values[index + 2] = float(color.blue); - values[index + 3] = 1; - index += 4; - } + CubeCache &cache = cubeCache(); + { + const std::lock_guard<std::mutex> held(cache.lock); + for (const auto &[known, values] : cache.cubes) { + if (known == settings) + return values; } } + std::vector<float> values = built(settings); + const std::lock_guard<std::mutex> held(cache.lock); + std::erase_if(cache.cubes, [&](const auto &entry) { return entry.first == settings; }); + cache.cubes.emplace_front(settings, values); + if (cache.cubes.size() > 8) + cache.cubes.pop_back(); return values; } @@ -144,8 +143,7 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green, hue = std::fmod(hue + sampled.shift, 360); if (hue < 0) hue += 360; - // Multiplied, so neutral grays stay neutral. - saturation = std::min(1.0, std::max(0.0, saturation * (1 + sampled.saturation / 100))); + saturation = adjustedSaturation(saturation, sampled.saturation); } // Toward white above zero, black below, reaching either at 100. const double amount = std::min(1.0, std::max(-1.0, lightnessAmount)); @@ -153,6 +151,15 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green, return toRGB(hue, saturation, std::min(1.0, std::max(0.0, lightness))); } +double HueSaturationFilter::adjustedSaturation(double saturation, double amount) +{ + const double share = std::min(1.0, std::max(-1.0, amount / 100)); + // Multiplied both ways, so neutral grays stay neutral. + if (!(share > 0)) + return std::max(0.0, saturation * (1 + share)); + return share >= 1 ? (saturation > 0 ? 1 : 0) : std::min(1.0, saturation / (1 - share)); +} + double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &settings) { double shift = 0; @@ -166,23 +173,19 @@ double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings & AdjustedPixels HueSaturationFilter::run(const HueSaturationJob &job) { - const int width = job.image.width(), height = job.image.height(); - QImage result = BrushRaster::context(width, height, false); - { - QPainter painter(&result); - BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter); - } + // Across the cores: it runs over the view each frame. + QImage result = BrushRaster::copy(job.image); std::vector<float> table; - std::vector<int> rows; try { table = cube(job.settings); - rows.resize(size_t(height)); } catch (const std::bad_alloc &) { // Out of memory fails as a context would. throw ExportError(ExportError::Kind::render); } - std::iota(rows.begin(), rows.end(), 0); - applyCube(result.bits(), result.bytesPerLine(), width, table, rows); + // The lookup unpremultiplies around itself. + uchar *const pixels = result.bits(); + BrushRaster::inBands(qsizetype(result.width()) * result.height(), + [&](qsizetype start, qsizetype length) { cube_apply(pixels + start * 4, size_t(length), table.data(), dimension); }); // Swift blends over the image drawn again: the same pixels. if (job.selection) result = PixelAdjust::blend(result, job.image, *job.selection, job.pixelToDocument, false); diff --git a/src/Document/HueSaturation.h b/src/Document/HueSaturation.h index 5ba7cbc..b302e8c 100644 --- a/src/Document/HueSaturation.h +++ b/src/Document/HueSaturation.h @@ -131,6 +131,8 @@ std::vector<HueResponse> hueResponse(const HueSaturationSettings &settings); std::vector<float> cube(const HueSaturationSettings &settings); Color adjust(double red, double green, double blue, const HueSaturationSettings &settings); Color adjust(double red, double green, double blue, const HueSaturationSettings &settings, const std::vector<HueResponse> &response); +// Photoshop's: down toward gray, up by dividing by what's left. +double adjustedSaturation(double saturation, double amount); // The hue a spectrum swatch becomes. double shiftedHue(double hue, const HueSaturationSettings &settings); } diff --git a/src/Document/LayerAdjustment.cpp b/src/Document/LayerAdjustment.cpp index fb69336..81a634a 100644 --- a/src/Document/LayerAdjustment.cpp +++ b/src/Document/LayerAdjustment.cpp @@ -242,7 +242,8 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region, FilterJob job{filterKind(kind).value(), image, settings, scale, std::nullopt, QTransform(), resolvedNoiseSeed()}; // Swift's region origin, counted in the image's own pixels. if (region) - job.noiseOrigin = region->topLeft() * (image.width() / region->width()); + job.noiseOrigin = QPointF(region->left() * image.width() / std::max(1.0, region->width()), + region->top() * image.height() / std::max(1.0, region->height())); return PixelFilter::run(job); } case AdjustmentKind::invert: return PixelInvert::run(PixelInvert::Job{image, false, QTransform(), std::nullopt}); diff --git a/src/Document/LayerGroups+Order.cpp b/src/Document/LayerGroups+Order.cpp new file mode 100644 index 0000000..a8c4ebd --- /dev/null +++ b/src/Document/LayerGroups+Order.cpp @@ -0,0 +1,108 @@ +#include "Document/EditorSession.h" +#include "Document/LayerGroups.h" +#include <map> +#include <mutex> +#include <stdexcept> + +namespace { +using Children = std::map<std::optional<QUuid>, std::vector<const LayerOrder::Node *>>; + +void visit(const Children &children, const std::optional<QUuid> &parent, int depth, bool shown, LayerOrder::Result &result) +{ + if (depth > 64) + return; + const auto found = children.find(parent); + if (found == children.end()) + return; + for (const LayerOrder::Node *node : found->second) { + const bool effective = shown && node->isVisible; + result.order.push_back(node->id); + if (effective) { + result.visible.insert(node->id); + if (!node->isGroup) + result.drawn.push_back(node->id); + } + if (node->isGroup) + visit(children, node->id, depth + 1, effective, result); + } +} + +// The last nodes asked about and their answer. +struct Known { + std::mutex lock; + std::optional<std::pair<std::vector<LayerOrder::Node>, LayerOrder::Result>> last; +}; + +Known &known() +{ + static Known value; + return value; +} +} + +LayerOrder::Result LayerOrder::resolve(const std::vector<ImageLayer> &layers) +{ + std::vector<Node> nodes; + nodes.reserve(layers.size()); + for (const ImageLayer &layer : layers) + nodes.push_back(Node{layer.id, layer.parentID, layer.isGroup, layer.isVisible}); + Known &cache = known(); + { + const std::lock_guard<std::mutex> held(cache.lock); + if (cache.last && cache.last->first == nodes) + return cache.last->second; + } + Children children; + for (const Node &node : nodes) + children[node.parentID].push_back(&node); + Result result; + visit(children, std::nullopt, 0, true, result); + const std::lock_guard<std::mutex> held(cache.lock); + cache.last.emplace(std::move(nodes), result); + return result; +} + +LayerOrder::Result CanvasDocument::hierarchy() const +{ + return LayerOrder::resolve(layers); +} + +QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const +{ + return hierarchy().visible; +} + +QHash<QUuid, double> CanvasDocument::effectiveOpacities() const +{ + // Each layer's opacity and folder, no whole layers copied. + QHash<QUuid, std::pair<double, std::optional<QUuid>>> folders; + for (const ImageLayer &layer : layers) { + if (folders.contains(layer.id)) + throw std::logic_error("two layers share an id"); + folders.insert(layer.id, {layer.opacity, layer.parentID}); + } + QHash<QUuid, double> result; + for (auto entry = folders.cbegin(); entry != folders.cend(); ++entry) { + result.insert(entry.key(), LayerOpacity::effective(entry->first, entry->second, [&folders](QUuid id) { + return folders.contains(id) ? std::optional(folders.value(id)) : std::nullopt; + })); + } + return result; +} + +std::vector<ImageLayer> CanvasDocument::renderLayers() const +{ + const std::vector<QUuid> order = hierarchy().drawn; + if (order.empty()) + return {}; + std::map<QUuid, size_t> index; + for (size_t place = 0; place < layers.size(); ++place) { + if (!index.emplace(layers[place].id, place).second) + throw std::logic_error("two layers share an id"); + } + std::vector<ImageLayer> result; + result.reserve(order.size()); + for (const QUuid id : order) + result.push_back(layers[index.at(id)]); + return result; +} diff --git a/src/Document/LayerGroups.cpp b/src/Document/LayerGroups.cpp index a3726bf..9e750af 100644 --- a/src/Document/LayerGroups.cpp +++ b/src/Document/LayerGroups.cpp @@ -121,19 +121,6 @@ double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRe }); } -QHash<QUuid, double> CanvasDocument::effectiveOpacities() const -{ - std::map<QUuid, ImageLayer> byID; - for (const ImageLayer &layer : layers) { - if (!byID.emplace(layer.id, layer).second) - throw std::logic_error("two layers share an id"); - } - QHash<QUuid, double> result; - for (const ImageLayer &layer : layers) - result.insert(layer.id, layer.effectiveOpacity(byID)); - return result; -} - ProjectLayerRecord ImageLayer::hierarchyRecord() const { const QString file = id.toString(QUuid::WithoutBraces).toUpper(); @@ -159,40 +146,6 @@ ProjectLayerRecord ImageLayer::hierarchyRecord() const .text = typed ? std::optional(typed->style) : std::nullopt}; } -std::vector<LayerHierarchy::Entry> CanvasDocument::hierarchyEntries() const -{ - std::vector<ProjectLayerRecord> records; - for (const ImageLayer &layer : layers) - records.push_back(layer.hierarchyRecord()); - return LayerHierarchy::entries(records); -} - -QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const -{ - QSet<QUuid> result; - for (const LayerHierarchy::Entry &entry : hierarchyEntries()) { - if (entry.visible) - result.insert(entry.layer.id); - } - return result; -} - -std::vector<ImageLayer> CanvasDocument::renderLayers() const -{ - QHash<QUuid, const ImageLayer *> byID; - for (const ImageLayer &layer : layers) { - if (byID.contains(layer.id)) - throw std::logic_error("two layers share an id"); - byID.insert(layer.id, &layer); - } - std::vector<ImageLayer> result; - for (const LayerHierarchy::Entry &entry : hierarchyEntries()) { - if (entry.visible && entry.layer.isGroup != true) - result.push_back(*byID.value(entry.layer.id)); - } - return result; -} - void EditorSession::selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary) { dropEffectSelection(); @@ -264,10 +217,10 @@ void EditorSession::groupSelectedLayers() } std::vector<QUuid> ordered; std::vector<Chain> chains; - for (const LayerHierarchy::Entry &entry : m_document->hierarchyEntries()) { - if (rootIDs.contains(entry.layer.id)) { - ordered.push_back(entry.layer.id); - chains.push_back(ancestors(entry.layer.id)); + for (const QUuid id : m_document->hierarchy().order) { + if (rootIDs.contains(id)) { + ordered.push_back(id); + chains.push_back(ancestors(id)); } } // The nearest folder they all share; the root when none. diff --git a/src/Document/LayerGroups.h b/src/Document/LayerGroups.h index 1cc60bf..d334a23 100644 --- a/src/Document/LayerGroups.h +++ b/src/Document/LayerGroups.h @@ -23,3 +23,26 @@ namespace LayerOpacity { using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>; double effective(double own, std::optional<QUuid> parent, const Node &folder); } + +struct ImageLayer; + +// Swift's LayerOrder: the hierarchy from ids, folders and visibility alone. +namespace LayerOrder { +struct Node { + QUuid id; + std::optional<QUuid> parentID; + bool isGroup; + bool isVisible; + friend bool operator==(const Node &, const Node &) = default; +}; +struct Result { + // Every layer and folder, as LayerHierarchy::entries lists them. + std::vector<QUuid> order; + // Visible, in folders that are. + QSet<QUuid> visible; + // The layers that show, folders left out, bottom to top. + std::vector<QUuid> drawn; +}; +// Kept until a node changes: the canvas asks each event. +Result resolve(const std::vector<ImageLayer> &layers); +} diff --git a/src/Document/LayerMask.cpp b/src/Document/LayerMask.cpp index 0e0013e..b34fc1a 100644 --- a/src/Document/LayerMask.cpp +++ b/src/Document/LayerMask.cpp @@ -392,7 +392,7 @@ std::optional<LayerTransform> EditorSession::displayedMaskPlacement(const ImageL if (!layer.mask) return std::nullopt; // Previewed on a grown layer, the mask keeps its bounds. - if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id)) + if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id)) return layer.mask->placement.value_or(layer.transform); if (m_transformEdit && m_transformEdit->group) { const TransformGroup &group = *m_transformEdit->group; diff --git a/src/Document/Levels.cpp b/src/Document/Levels.cpp index 1da0396..3d1aee3 100644 --- a/src/Document/Levels.cpp +++ b/src/Document/Levels.cpp @@ -104,15 +104,17 @@ QImage LevelsFilter::run(const LevelsJob &job) { if (job.settings.isIdentity()) return job.image; - QImage context = drawn(job.image); + QImage context = BrushRaster::copy(job.image); // On the stack: a heap failure would escape the catch. std::array<float, 3 * 256> tables; for (size_t channel = 0; channel < 3; ++channel) { for (size_t value = 0; value <= 255; ++value) tables[channel * 256 + value] = float(job.settings.apply(double(value) / 255, allLevelsChannels[channel + 1])); } - // The kernel unpremultiplies each edge itself (Swift's cc5aac4 does twice). - levels_apply(context.bits(), size_t(context.width()) * size_t(context.height()), tables.data()); + // The kernel unpremultiplies edges itself; bands use every core. + uchar *const pixels = context.bits(); + BrushRaster::inBands(qsizetype(context.width()) * context.height(), + [&](qsizetype start, qsizetype length) { levels_apply(pixels + start * 4, size_t(length), tables.data()); }); if (job.selection) return PixelAdjust::blend(context, job.image, *job.selection, job.mapping, false); return context; diff --git a/src/IO/PSD/PSDDocumentBuilder.cpp b/src/IO/PSD/PSDDocumentBuilder.cpp index 80754a8..dffd8bc 100644 --- a/src/IO/PSD/PSDDocumentBuilder.cpp +++ b/src/IO/PSD/PSDDocumentBuilder.cpp @@ -1,6 +1,10 @@ #include "IO/PSD/PSDDocumentBuilder.h" #include "Document/PixelAdjust.h" #include "IO/ProjectStore.h" +#include "Document/BrushStroke.h" +#include "IO/ImageExporter.h" +#include "Logging.h" +#include <QPainter> namespace { ImportedImage imported(const QImage &image, const QString &name) @@ -114,7 +118,10 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std: ImageLayer layer = layerFor(record, canvas, assets, typed); if (record.mask) { try { - layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked); + const std::optional<QImage> placed = maskOnLayerGrid(*record.mask, record, layer, canvas); + if (!placed) + throw ExportError(ExportError::Kind::render); + layer.mask = LayerMask(LayerMask::assetFrom(*placed), record.maskEnabled, std::nullopt, record.maskLinked); } catch (const std::runtime_error &) { // Swift's `try?`: a mask that fails is reported, not fatal. conversions.push_back(PSDConversion{.layerName = record.name, @@ -147,3 +154,28 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std: } return PSDImport{document.width, document.height, document.resolution, std::move(layers), std::move(conversions)}; } + +std::optional<QImage> PSDDocumentBuilder::maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas) +{ + const QSizeF grid = layer.asset ? QSizeF(layer.asset->size()) : canvas; + const QRectF placed(layer.transform.origin, layer.transform.size); + if (grid.width() < 1 || grid.height() < 1 || !(placed.width() > 0) || !(placed.height() > 0) || !(record.maskBounds.width() > 0) + || !(record.maskBounds.height() > 0)) + return patch; + const double scaleX = grid.width() / placed.width(), scaleY = grid.height() / placed.height(); + const QRectF rect((record.maskBounds.left() - placed.left()) * scaleX, (record.maskBounds.top() - placed.top()) * scaleY, + record.maskBounds.width() * scaleX, record.maskBounds.height() * scaleY); + const int width = int(grid.width()), height = int(grid.height()); + // Already the layer's grid: nothing to place. + if (rect.toAlignedRect() == QRect(0, 0, width, height) && patch.size() == QSize(width, height)) + return patch; + QImage context(width, height, QImage::Format_Grayscale8); + if (context.isNull()) { + qCWarning(lcIO) << "a PSD layer mask could not be placed on its layer:" << QSize(width, height); + return std::nullopt; + } + context.fill(uint(record.maskDefault)); + QPainter painter(&context); + BrushRaster::draw(patch, rect, painter); + return context; +} diff --git a/src/IO/PSD/PSDDocumentBuilder.h b/src/IO/PSD/PSDDocumentBuilder.h index 0645721..f7fb5ad 100644 --- a/src/IO/PSD/PSDDocumentBuilder.h +++ b/src/IO/PSD/PSDDocumentBuilder.h @@ -15,4 +15,6 @@ namespace PSDDocumentBuilder { // Each pixel record's asset and thumbnail, off the UI thread. std::map<QUuid, ImportedImage> assets(const PSDDocument &document); PSDImport makeImport(const PSDDocument &document, const std::map<QUuid, ImportedImage> &assets = {}); +// The stored patch on the layer's grid; none without memory. +std::optional<QImage> maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas); } diff --git a/src/IO/PSD/PSDReader+Adjustments.cpp b/src/IO/PSD/PSDReader+Adjustments.cpp index 2ef0b1a..83c4c54 100644 --- a/src/IO/PSD/PSDReader+Adjustments.cpp +++ b/src/IO/PSD/PSDReader+Adjustments.cpp @@ -1,6 +1,7 @@ #include "IO/PSD/PSDReader.h" #include <QtEndian> #include <algorithm> +#include <cmath> namespace { quint16 u16(const QByteArray &data, qsizetype at) @@ -12,21 +13,23 @@ qint16 i16(const QByteArray &data, qsizetype at) { return qFromBigEndian<qint16>(data.constData() + at); } +} -std::optional<LayerAdjustment> levels(const QByteArray &data) +std::optional<LayerAdjustment> PSDAdjustments::levels(const QByteArray &data) { if (data.size() < 292) return std::nullopt; LevelsSettings settings; for (int channel = 0; channel < 4; ++channel) { const qsizetype base = 2 + channel * 10; - settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 256, double(u16(data, base + 2)), + settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 100, double(u16(data, base + 2)), double(u16(data, base + 4)), double(u16(data, base + 6))} .normalized(); } return LayerAdjustment{.kind = AdjustmentKind::levels, .levels = settings}; } +namespace { std::optional<LayerAdjustment> curves(const QByteArray &data) { if (data.size() < 5) @@ -66,22 +69,33 @@ std::optional<LayerAdjustment> curves(const QByteArray &data) return std::nullopt; return LayerAdjustment{.kind = AdjustmentKind::curves, .curves = settings}; } +} -std::optional<LayerAdjustment> hue(const QByteArray &data) +std::optional<LayerAdjustment> PSDAdjustments::hue(const QByteArray &data) { - if (data.size() < 4) + if (data.size() < 16) return std::nullopt; - HueSaturationSettings settings(0, 0, 0, data[2] != 0); - qsizetype at = 4; - for (const ColorRange range : allColorRanges) { - if (at + 6 > data.size()) + const bool colorize = data[2] != 0; + HueSaturationSettings settings(0, 0, 0, colorize); + const auto values = [&data](qsizetype at) { return RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))}; }; + // Colorize has values of its own; else the Master's. + settings.adjustments[ColorRange::master] = values(colorize ? 4 : 10); + if (colorize) + return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings}; + qsizetype at = 16; + for (const ColorRange range : colorRanges) { + if (at + 14 > data.size()) break; - settings.adjustments[range] = RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))}; - at += 6; + const auto degrees = [&data](qsizetype from) { + const double value = std::fmod(double(i16(data, from)), 360); + return value < 0 ? value + 360 : value; + }; + settings.bands[range] = HueBand{degrees(at), degrees(at + 2), degrees(at + 4), degrees(at + 6)}; + settings.adjustments[range] = values(at + 8); + at += 14; } return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings}; } -} std::optional<LayerAdjustment> PSDAdjustments::parse(const std::map<QString, QByteArray> &extra) { diff --git a/src/IO/PSD/PSDReader+Records.cpp b/src/IO/PSD/PSDReader+Records.cpp index 4b55aeb..008e342 100644 --- a/src/IO/PSD/PSDReader+Records.cpp +++ b/src/IO/PSD/PSDReader+Records.cpp @@ -180,8 +180,7 @@ PSDRecords::RawLayer PSDRecords::readRecord(Cursor &cursor, bool isPSB) layer.maskRight = cursor.i32(); layer.sourceMaskTop = layer.maskTop, layer.sourceMaskLeft = layer.maskLeft; layer.sourceMaskBottom = layer.maskBottom, layer.sourceMaskRight = layer.maskRight; - // The mask's default colour is skipped, as Swift ignores it. - cursor.skip(1); + layer.maskDefault = cursor.u8(); const uchar flags = cursor.u8(); layer.maskDisabled = (flags & 2) != 0; layer.maskLinked = (flags & 1) == 0; diff --git a/src/IO/PSD/PSDReader+Records.h b/src/IO/PSD/PSDReader+Records.h index 64b1065..dd10d9d 100644 --- a/src/IO/PSD/PSDReader+Records.h +++ b/src/IO/PSD/PSDReader+Records.h @@ -50,6 +50,8 @@ struct RawLayer { std::map<QString, QByteArray> extra; qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0; qint64 sourceMaskTop = 0, sourceMaskLeft = 0, sourceMaskBottom = 0, sourceMaskRight = 0; + // The mask's value outside its stored bounds. + uchar maskDefault = 255; bool maskDisabled = false; bool maskLinked = true; bool maskFromRender = false; diff --git a/src/IO/PSD/PSDReader.cpp b/src/IO/PSD/PSDReader.cpp index bdec74d..b0c47b2 100644 --- a/src/IO/PSD/PSDReader.cpp +++ b/src/IO/PSD/PSDReader.cpp @@ -80,6 +80,8 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas, } } record.mask = layer.maskFromRender ? std::nullopt : layer.maskImage; + record.maskBounds = QRectF(layer.maskLeft, layer.maskTop, layer.maskRight - layer.maskLeft, layer.maskBottom - layer.maskTop); + record.maskDefault = layer.maskDefault; record.maskEnabled = !layer.maskDisabled; record.maskLinked = layer.maskLinked; if (!isGroup) diff --git a/src/IO/PSD/PSDReader.h b/src/IO/PSD/PSDReader.h index e92d3e3..a9c22ae 100644 --- a/src/IO/PSD/PSDReader.h +++ b/src/IO/PSD/PSDReader.h @@ -21,4 +21,8 @@ extern const std::set<QString> adjustmentKeys; // Levels, Curves and Hue/Saturation layers, as Swift reads them. namespace PSDAdjustments { std::optional<LayerAdjustment> parse(const std::map<QString, QByteArray> &extra); +// 'levl': each channel's levels, gamma in hundredths. +std::optional<LayerAdjustment> levels(const QByteArray &data); +// 'hue2': Colorize or the Master, then each range's values. +std::optional<LayerAdjustment> hue(const QByteArray &data); } diff --git a/src/IO/PSD/PSDTypes.h b/src/IO/PSD/PSDTypes.h index a49d965..0edafa2 100644 --- a/src/IO/PSD/PSDTypes.h +++ b/src/IO/PSD/PSDTypes.h @@ -38,6 +38,9 @@ struct PSDRecord { QRectF bounds; std::optional<QImage> image; std::optional<QImage> mask; + // Where `mask` sits, and Photoshop's value everywhere else. + QRectF maskBounds; + uchar maskDefault = 255; bool maskEnabled = true; bool maskLinked = true; std::optional<LayerAdjustment> adjustment; diff --git a/src/Rendering/AdjustmentSurface.cpp b/src/Rendering/AdjustmentSurface.cpp index d60707e..7eafa33 100644 --- a/src/Rendering/AdjustmentSurface.cpp +++ b/src/Rendering/AdjustmentSurface.cpp @@ -1,6 +1,7 @@ #include "Rendering/AdjustmentSurface.h" #include "Document/BrushStroke.h" #include "Logging.h" +#include "Rendering/LayerRenderer.h" #include <QPaintEngine> void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding, qint64 pixelBudget) @@ -15,11 +16,18 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte // Spatial adjustments read a halo; the clip keeps what shows. const QRectF shown = toDevice.inverted().mapRect(QRectF(output)); const QRect area = padding > 0 ? toDevice.mapRect(shown.adjusted(-padding, -padding, padding, padding)).toAlignedRect() : output; - if (qint64(area.width()) * area.height() > pixelBudget) { + // Device pixels; past the budget, painter units. + double scale = 1; + QSize size = area.size(); + if (qint64(size.width()) * size.height() > pixelBudget) { + scale = 1 / LayerRenderer::deviceScale(context); + size = QSize(std::max(1, qRound(area.width() * scale)), std::max(1, qRound(area.height() * scale))); + } + if (qint64(size.width()) * size.height() > pixelBudget) { qCWarning(lcRendering) << "an adjustment surface passes its pixel budget:" << area.size(); return; } - QImage surface(area.size(), QImage::Format_RGBA8888_Premultiplied); + QImage surface(size, QImage::Format_RGBA8888_Premultiplied); if (surface.isNull()) { qCWarning(lcRendering) << "an adjustment surface could not be allocated:" << area.size(); return; @@ -28,12 +36,13 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte { // The body draws in the painter's own coordinates. QPainter painter(&surface); - painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top())); + painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top()) * QTransform::fromScale(scale, scale)); body(painter); } // Drawn back under the painter's own opacity, mode and clip. context.save(); context.setWorldTransform(context.deviceTransform().inverted() * context.worldTransform()); + context.setRenderHint(QPainter::SmoothPixmapTransform, scale != 1); context.drawImage(QRectF(area), surface); context.restore(); } diff --git a/src/Rendering/LayerRenderer.cpp b/src/Rendering/LayerRenderer.cpp index 114680c..cd7e691 100644 --- a/src/Rendering/LayerRenderer.cpp +++ b/src/Rendering/LayerRenderer.cpp @@ -56,7 +56,7 @@ void LayerRenderer::draw(const QImage &image, const LayerTransform &transform, Q const double device = deviceScale(context); const Reduced source = reduced(image, width, device, transform.sampling); const InterpolationQuality quality = interpolation( - transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level)); + transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level), transform.radians() == 0); const QRectF bounds(-width / 2, -height / 2, width, height); QTransform placement; placement.translate(center.x(), center.y()); @@ -294,9 +294,9 @@ void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &tran context.restore(); } -InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor) +InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor, bool upright) { - if (sampling == LayerSampling::nearest) + if (sampling == LayerSampling::nearest || (upright && std::abs(finalFactor - 1) < 0.001)) return InterpolationQuality::none; return finalFactor <= 1 ? InterpolationQuality::low : quality(sampling); } diff --git a/src/Rendering/LayerRenderer.h b/src/Rendering/LayerRenderer.h index 1bcc29e..3386756 100644 --- a/src/Rendering/LayerRenderer.h +++ b/src/Rendering/LayerRenderer.h @@ -57,7 +57,8 @@ void drawBrushPreview(const QImage &image, const LayerTransform &transform, QPoi void composite(QPainter &context, const QTransform &placement, const QRectF &extent, LayerSampling sampling, InterpolationQuality quality, const Options &options, const QRectF &maskBounds, const std::function<void(QPainter &)> &body, const std::function<void(QPainter &)> &veil = {}); -InterpolationQuality interpolation(LayerSampling sampling, double finalFactor); +// An upright layer drawn pixel for pixel copies straight across. +InterpolationQuality interpolation(LayerSampling sampling, double finalFactor, bool upright = false); Reduced reduced(const QImage &image, double width, double device, LayerSampling sampling); QRectF coverage(const Reduced &reduced, const QRectF &bounds); double deviceScale(const QPainter &context); diff --git a/src/Rendering/TiledLayerRenderer.cpp b/src/Rendering/TiledLayerRenderer.cpp index 7f59940..d9a56a7 100644 --- a/src/Rendering/TiledLayerRenderer.cpp +++ b/src/Rendering/TiledLayerRenderer.cpp @@ -150,7 +150,8 @@ void withFrame(int pixelWidth, int pixelHeight, const QRectF &painted, const QRe return; const double device = LayerRenderer::deviceScale(context); const int level = transform.sampling == LayerSampling::nearest ? 0 : DownsampleCache::level(width * device / pixelWidth); - const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level)); + const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level), + transform.radians() == 0); QTransform placement; placement.translate(center.x(), center.y()); placement.rotateRadians(transform.radians()); diff --git a/tests/AdjustmentBandsTests.cpp b/tests/AdjustmentBandsTests.cpp new file mode 100644 index 0000000..4664e7e --- /dev/null +++ b/tests/AdjustmentBandsTests.cpp @@ -0,0 +1,124 @@ +#include "Document/BrushStroke.h" +#include "Document/HueSaturation.h" +#include "Document/Levels.h" +#include <QRandomGenerator> +#include <QtTest> +#include <mutex> +extern "C" { +#include "LevelsPixels.h" +} + +// Compositor 1.4: Levels and Hue/Saturation in bands across the cores. +namespace { +// Premultiplied noise, some of it partly clear. +QImage noise(int width, int height) +{ + QImage image = BrushRaster::context(width, height, false); + QRandomGenerator random(7); + for (int y = 0; y < height; ++y) { + uchar *row = image.scanLine(y); + for (int x = 0; x < width; ++x) { + const uchar alpha = uchar(random.bounded(256)); + for (int channel = 0; channel < 3; ++channel) + row[x * 4 + channel] = uchar(random.bounded(alpha + 1)); + row[x * 4 + 3] = alpha; + } + } + return image; +} + +std::vector<uchar> bytes(const QImage &image) +{ + std::vector<uchar> result; + for (int y = 0; y < image.height(); ++y) + result.insert(result.end(), image.constScanLine(y), image.constScanLine(y) + image.width() * 4); + return result; +} +} + +class AdjustmentBandsTests : public QObject { + Q_OBJECT +private slots: + void bandsCoverEveryPixelOnce(); + void aCopyKeepsTheBytes(); + void levelsInBandsMatchesOnePass(); + void hueSaturationInBandsMatchesOnePass(); + void cachedTablesFollowTheirSettings(); +}; + +void AdjustmentBandsTests::bandsCoverEveryPixelOnce() +{ + for (const qsizetype count : {qsizetype(10), qsizetype(249'999), qsizetype(250'007), qsizetype(1'000'003)}) { + std::vector<int> marks(size_t(count), 0); + std::mutex lock; + int calls = 0; + BrushRaster::inBands(count, [&](qsizetype start, qsizetype length) { + QVERIFY(length > 0); + for (qsizetype index = start; index < start + length; ++index) + ++marks[size_t(index)]; + const std::lock_guard<std::mutex> held(lock); + ++calls; + }); + QVERIFY(std::all_of(marks.begin(), marks.end(), [](int mark) { return mark == 1; })); + // Small counts run as one band. + QCOMPARE(calls > 1, count >= 250'000); + } +} + +void AdjustmentBandsTests::aCopyKeepsTheBytes() +{ + const QImage source = noise(37, 11); + const QImage copied = BrushRaster::copy(source); + QCOMPARE(copied.format(), QImage::Format_RGBA8888_Premultiplied); + QCOMPARE(bytes(copied), bytes(source)); + QVERIFY(copied.constBits() != source.constBits()); + // Another layout is drawn into one. + QImage argb(3, 1, QImage::Format_ARGB32); + argb.fill(qRgba(255, 0, 0, 255)); + QCOMPARE(bytes(BrushRaster::copy(argb)), (std::vector<uchar>{255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255})); +} + +void AdjustmentBandsTests::levelsInBandsMatchesOnePass() +{ + const QImage source = noise(701, 401); + LevelsSettings settings; + settings.ranges[0] = LevelRange{.black = 20, .gamma = 1.7, .white = 230, .outputBlack = 10, .outputWhite = 240}; + std::array<float, 3 * 256> tables; + for (size_t channel = 0; channel < 3; ++channel) { + for (size_t value = 0; value <= 255; ++value) + tables[channel * 256 + value] = float(settings.apply(double(value) / 255, allLevelsChannels[channel + 1])); + } + QImage reference = source.copy(); + levels_apply(reference.bits(), size_t(reference.width()) * size_t(reference.height()), tables.data()); + const QImage banded = LevelsFilter::run(LevelsJob{source, settings, std::nullopt, QTransform()}); + QCOMPARE(bytes(banded), bytes(reference)); + QVERIFY(bytes(banded) != bytes(source)); +} + +void AdjustmentBandsTests::hueSaturationInBandsMatchesOnePass() +{ + const QImage source = noise(701, 401); + const HueSaturationSettings settings(120, 40, -10); + const std::vector<float> cube = HueSaturationFilter::cube(settings); + QImage reference = source.copy(); + cube_apply(reference.bits(), size_t(reference.width()) * size_t(reference.height()), cube.data(), HueSaturationFilter::dimension); + const QImage banded = HueSaturationFilter::run(HueSaturationJob{source, settings, std::nullopt, QTransform(), false}).image; + QCOMPARE(bytes(banded), bytes(reference)); + QVERIFY(bytes(banded) != bytes(source)); +} + +void AdjustmentBandsTests::cachedTablesFollowTheirSettings() +{ + // More settings than the cache holds, asked twice over. + std::vector<std::vector<float>> first; + for (int hue = 0; hue < 10; ++hue) + first.push_back(HueSaturationFilter::cube(HueSaturationSettings(hue * 30))); + for (int round = 0; round < 2; ++round) { + for (int hue = 9; hue >= 0; --hue) + QVERIFY(HueSaturationFilter::cube(HueSaturationSettings(hue * 30)) == first[size_t(hue)]); + } + QVERIFY(first[0] != first[1]); +} + +QTEST_GUILESS_MAIN(AdjustmentBandsTests) +#include "AdjustmentBandsTests.moc" diff --git a/tests/AdjustmentSurfaceTests.cpp b/tests/AdjustmentSurfaceTests.cpp index 95391d2..336e77c 100644 --- a/tests/AdjustmentSurfaceTests.cpp +++ b/tests/AdjustmentSurfaceTests.cpp @@ -107,12 +107,31 @@ void AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn() { QImage surface = BrushRaster::context(8, 8, false); QPainter painter(&surface); - // The budget counts device pixels: 64 here, not 16 units. + // 64 device pixels; past that 16 units; past that, none. painter.scale(2, 2); bool called = false; QTest::ignoreMessage(QtWarningMsg, "an adjustment surface passes its pixel budget: QSize(8, 8)"); - AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 63); + AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 15); QVERIFY(!called); + QSize allocated; + AdjustmentSurface::draw(painter, [&](QPainter &target) { + allocated = QSize(target.device()->width(), target.device()->height()); + target.fillRect(QRectF(0, 0, 2, 4), Qt::blue); + target.fillRect(QRectF(2, 0, 2, 4), Qt::green); + }, 0, 63); + QCOMPARE(allocated, QSize(4, 4)); + // A device pixel at a quarter: one pixel, not none. + painter.save(); + painter.scale(2, 2); + painter.setClipRect(QRectF(0, 0, 0.25, 1)); + allocated = QSize(); + AdjustmentSurface::draw(painter, [&](QPainter &target) { allocated = QSize(target.device()->width(), target.device()->height()); }, 0, 3); + painter.restore(); + QCOMPARE(allocated, QSize(1, 1)); + // Enlarged smoothly: the seam between blue and green blends. + QCOMPARE(surface.pixel(0, 0), qRgba(0, 0, 255, 255)); + QCOMPARE(surface.pixel(7, 0), qRgba(0, 255, 0, 255)); + QVERIFY(qBlue(surface.pixel(4, 0)) > 0 && qBlue(surface.pixel(4, 0)) < 255); AdjustmentSurface::draw(painter, [&](QPainter &target) { called = true; target.fillRect(QRectF(0, 0, 4, 4), Qt::red); diff --git a/tests/BlurNoiseAdjustmentTests.cpp b/tests/BlurNoiseAdjustmentTests.cpp index 2948147..2be6384 100644 --- a/tests/BlurNoiseAdjustmentTests.cpp +++ b/tests/BlurNoiseAdjustmentTests.cpp @@ -295,6 +295,14 @@ void BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions() QImage expected = gray.copy(); noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 40); QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 4)), expected); + // Each axis by its own scale (Compositor 1.4). + expected = gray.copy(); + noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 10); + QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 16)), expected); + // A region under a unit wide counts as one. + expected = gray.copy(); + noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 8, 16); + QCOMPARE(noise.apply(gray, QRectF(1, 2, 0.5, 0.5)), expected); // A negative origin floors, as Swift rounds down. expected = gray.copy(); noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, -11, -21); diff --git a/tests/FilterSessionTests.cpp b/tests/FilterSessionTests.cpp index a5c70f9..4c54659 100644 --- a/tests/FilterSessionTests.cpp +++ b/tests/FilterSessionTests.cpp @@ -119,7 +119,8 @@ void FilterSessionTests::updatesNormalizeGrowAndDropWhatPreviewOffHides() session->updateFilter(FilterSettings{.radius = 1000}, true); QCOMPARE(settings(*session).radius, 250.0); QCOMPARE(session->filterEdit().value().grownMargin, 752.0); - QVERIFY(!session->filterEdit().value().preparedPreview); + // The last preview stays up while the grown grid renders. + QVERIFY(session->filterEdit().value().preparedPreview); QVERIFY(settled(*session) && session->filterEdit().value().preparedPreview); // Off: the preview and the queue go at once. const int revision = session->brushRevision(); diff --git a/tests/FilterTests.cpp b/tests/FilterTests.cpp index 9523e8d..0d6d518 100644 --- a/tests/FilterTests.cpp +++ b/tests/FilterTests.cpp @@ -34,6 +34,8 @@ class FilterTests : public QObject { Q_OBJECT private slots: void gaussianBlurSoftensTheEdgesIntoRoomMadeForItAsOneUndoStep(); + void growingABlurKeepsThePreviewUpUntilTheNextOne(); + void aMaskKeepsItsPlaceUnderAPreview(); void motionBlurStreaksAlongItsAngleCounterclockwiseFromHorizontal(); void addNoiseChangesColorButNeverAlphaAndMonochromaticKeepsGrays(); void removeDistortionBendsAboutTheCenterAndOnlyPincushionCorrectionOpensTheCorners(); @@ -70,6 +72,56 @@ void FilterTests::gaussianBlurSoftensTheEdgesIntoRoomMadeForItAsOneUndoStep() QCOMPARE(alpha(27), 1); } +// The last preview stays where made until replaced. +void FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne() +{ + EditorSession session; + session.createDocument(40, 20); + QImage image = BrushRaster::context(40, 20, false); + QPainter(&image).fillRect(0, 0, 20, 20, Qt::white); + session.insert(ImportedImage(image, image, QStringLiteral("Half"))); + const ImageLayer layer = session.activeLayer().value(); + session.beginFilter(FilterKind::gaussianBlur); + session.updateFilter(FilterSettings{.radius = 2}, true); + QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000)); + const QImage first = session.filterEdit().value().previewImage(layer.id).value(); + const LayerTransform firstPlace = session.displayedTransform(session.activeLayer().value()); + QVERIFY(firstPlace.size.width() > 40); + QCOMPARE(session.displayedMaskPlacement(session.activeLayer().value()), std::nullopt); + session.updateFilter(FilterSettings{.radius = 12}, true); + QCOMPARE(session.filterEdit().value().previewImage(layer.id).value().cacheKey(), first.cacheKey()); + QCOMPARE(session.displayedTransform(session.activeLayer().value()), firstPlace); + QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000)); + const QImage second = session.filterEdit().value().previewImage(layer.id).value(); + QVERIFY(second.cacheKey() != first.cacheKey()); + QVERIFY(session.displayedTransform(session.activeLayer().value()).size.width() > firstPlace.size.width()); + session.cancelFilter(); +} + +// A covering mask covers; on a grown preview, old bounds. +void FilterTests::aMaskKeepsItsPlaceUnderAPreview() +{ + EditorSession session; + session.createDocument(40, 20); + QImage image = BrushRaster::context(40, 20, false); + image.fill(Qt::white); + session.insert(ImportedImage(image, image, QStringLiteral("White"))); + session.addLayerMask(); + const ImageLayer layer = session.activeLayer().value(); + session.selectLayerTarget(layer.id, false); + session.beginFilter(FilterKind::addNoise); + QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000)); + QVERIFY(session.filterEdit().value().previewImage(layer.id)); + QCOMPARE(session.displayedMaskPlacement(layer), std::nullopt); + QCOMPARE(session.displayedTransform(layer), layer.transform); + session.cancelFilter(); + session.beginFilter(FilterKind::gaussianBlur); + QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000)); + QCOMPARE(session.displayedMaskPlacement(layer), std::optional(layer.transform)); + QVERIFY(session.displayedTransform(layer).size.width() > 40); + session.cancelFilter(); +} + void FilterTests::motionBlurStreaksAlongItsAngleCounterclockwiseFromHorizontal() { const auto streak = [](double angle) { diff --git a/tests/FinishingFilterTests.cpp b/tests/FinishingFilterTests.cpp index 988c7dd..0219e01 100644 --- a/tests/FinishingFilterTests.cpp +++ b/tests/FinishingFilterTests.cpp @@ -71,8 +71,8 @@ private slots: void settingsKeepSwiftsDefaultsAndBounds(); void nothingToChangeClosesWithoutAStep(); void bloomGrowsItsGridAndIsTrimmed(); - void aPreviewForAnOlderGridLandsNothing(); - void aJobWaitingBehindAnotherEditTakesTheNewGrid(); + void aPreviewForAnOlderGridShowsOnThatGrid(); + void aJobWaitingBehindAnotherEditShowsOnItsGrid(); void theVignettePickerPreviewsAndPutsBack(); void theSheetShowsSwiftsControls(); }; @@ -264,28 +264,33 @@ void FinishingFilterTests::bloomGrowsItsGridAndIsTrimmed() QVERIFY(after.size.width() > before.size.width() && after.size.width() < before.size.width() + 2 * 74); } -void FinishingFilterTests::aPreviewForAnOlderGridLandsNothing() +void FinishingFilterTests::aPreviewForAnOlderGridShowsOnThatGrid() { EditorSession session; session.createDocument(1200, 1200); session.insert(ImportedImage(filled(1200, 1200, QColor(90, 90, 90)), QImage(), QStringLiteral("Big"))); session.beginFilter(FilterKind::bloomGlow); QTRY_VERIFY(!session.filterEdit().value().preparing); - // Every landed preview matches the grid it shows on. + // Every landed preview sits on the grid it came from. bool mismatched = false; + int older = 0; QObject::connect(&session, &EditorSession::changed, &session, [&] { const std::optional<FilterEdit> &edit = session.filterEdit(); - if (edit && edit->preparedPreview && edit->preparedPreview->size() != edit->previewSource.size()) + if (!edit || !edit->preparedPreview) + return; + if (QSizeF(edit->preparedPreview->size()) != edit->preparedTransform.value().size) mismatched = true; + older += edit->preparedPreview->size() != edit->previewSource.size(); }); session.updateFilter(FilterSettings{.bloomRadius = 5}, true); - const quint64 version = session.filterEdit().value().previewSourceVersion; + const LayerTransform grid = session.filterEdit().value().grownTransform.value(); // Wider than the grid: it grows mid-render. session.updateFilter(FilterSettings{.bloomRadius = 40}, true); - QCOMPARE(session.filterEdit().value().previewSourceVersion, version + 1); + QVERIFY(session.filterEdit().value().grownTransform.value().size.width() > grid.size.width()); QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing); - QVERIFY(!mismatched); + QVERIFY(!mismatched && older > 0); QCOMPARE(session.filterEdit().value().preparedPreview->size(), session.filterEdit().value().previewSource.size()); + QCOMPARE(session.filterEdit().value().preparedTransform, session.filterEdit().value().grownTransform); // Preview off: nothing waits; the stale render ends it. session.updateFilter(FilterSettings{.bloomRadius = 5}, true); session.updateFilter(FilterSettings{.bloomRadius = 60}, false); @@ -293,7 +298,7 @@ void FinishingFilterTests::aPreviewForAnOlderGridLandsNothing() QVERIFY(!session.filterEdit().value().preparedPreview); } -void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid() +void FinishingFilterTests::aJobWaitingBehindAnotherEditShowsOnItsGrid() { // The reviewer's sequence: a cancelled edit's worker still runs. EditorSession session; @@ -302,13 +307,13 @@ void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid() session.beginFilter(FilterKind::gaussianBlur); QVERIFY(session.filterEdit().value().preparing); session.cancelFilter(); - // Bloom's first job waits; Preview off grows its grid meanwhile. + // Bloom's first job waits; Preview off grows the grid meanwhile. session.beginFilter(FilterKind::bloomGlow); session.updateFilter(FilterSettings{.bloomRadius = 50}, false); bool mismatched = false, armed = true; QObject::connect(&session, &EditorSession::changed, &session, [&] { const std::optional<FilterEdit> &edit = session.filterEdit(); - if (edit && edit->preparedPreview && edit->preparedPreview->size() != edit->previewSource.size()) + if (edit && edit->preparedPreview && QSizeF(edit->preparedPreview->size()) != edit->preparedTransform.value().size) mismatched = true; // The waiting job starts: Preview comes back next turn. if (std::exchange(armed, false)) @@ -317,6 +322,7 @@ void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid() QTRY_VERIFY(!armed); QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing); QVERIFY(!mismatched); + QCOMPARE(session.filterEdit().value().preparedPreview->size(), session.filterEdit().value().previewSource.size()); } void FinishingFilterTests::theVignettePickerPreviewsAndPutsBack() diff --git a/tests/GroupTests.cpp b/tests/GroupTests.cpp index 56abe7e..066a66a 100644 --- a/tests/GroupTests.cpp +++ b/tests/GroupTests.cpp @@ -214,8 +214,8 @@ void GroupTests::documentHierarchyFollowsItsLayers() shown.parentID = open.id; ImageLayer top("Top", document.size()); document.layers = {folder, child, open, shown, top}; - QCOMPARE(names(document.hierarchyEntries()), - QStringList({"Folder:0:hidden", "Child:1:hidden", "Open:0:shown", "Shown:1:shown", "Top:0:shown"})); + QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, child.id, open.id, shown.id, top.id})); + QCOMPARE(document.hierarchy().drawn, (std::vector<QUuid>{shown.id, top.id})); QCOMPARE(document.effectiveVisibleIDs(), QSet<QUuid>({open.id, shown.id, top.id})); const std::vector<ImageLayer> rendered = document.renderLayers(); QCOMPARE(int(rendered.size()), 2); diff --git a/tests/HueSaturationFailureTests.cpp b/tests/HueSaturationFailureTests.cpp index 81de930..937b17b 100644 --- a/tests/HueSaturationFailureTests.cpp +++ b/tests/HueSaturationFailureTests.cpp @@ -83,12 +83,12 @@ void HueSaturationFailureTests::failuresReachTheSessionsError() void HueSaturationFailureTests::theFilterFailsAsAContextWhenMemoryRunsOut() { - // One pixel wide: its row list asks as its pixels. + // Its copy asks 64 MB, past the limit. QImage tall(1, 16'000'000, QImage::Format_RGBA8888_Premultiplied); tall.fill(Qt::red); const HueSaturationJob job{tall, HueSaturationSettings(120), std::nullopt, QTransform(), false}; malloc_trim(0); - const AddressSpaceLimit limit(80ll * 1024 * 1024); + const AddressSpaceLimit limit(16ll * 1024 * 1024); QVERIFY_THROWS_EXCEPTION(ExportError, HueSaturationFilter::run(job)); } diff --git a/tests/HueSaturationTests.cpp b/tests/HueSaturationTests.cpp index 4455d7c..9d67e12 100644 --- a/tests/HueSaturationTests.cpp +++ b/tests/HueSaturationTests.cpp @@ -111,6 +111,7 @@ private slots: void eyedroppersRecenterWidenAndNarrowTheBand(); void targetedAdjustmentPicksTheRangeUnderTheCursor(); void samplingNeedsAColorRangeAndAColorfulPixel(); + void positiveSaturationMatchesPhotoshop(); }; void HueSaturationTests::defaultsAreAnExactNoOp() @@ -319,5 +320,23 @@ void HueSaturationTests::samplingNeedsAColorRangeAndAColorfulPixel() QVERIFY(gray->hueSaturation().value().settings.band() == before); } +// Photoshop divides by what's left: +50 doubles, +100 saturates. +void HueSaturationTests::positiveSaturationMatchesPhotoshop() +{ + QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.2, 50) - 0.4) < 1e-9); + QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.3, 62) - 0.3 / 0.38) < 1e-9); + QCOMPARE(HueSaturationFilter::adjustedSaturation(0.1, 100), 1.0); + QCOMPARE(HueSaturationFilter::adjustedSaturation(0.8, 50), 1.0); + QCOMPARE(HueSaturationFilter::adjustedSaturation(0, 100), 0.0); + QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.6, -50) - 0.3) < 1e-9); + // Clamped past ±100. + QCOMPARE(HueSaturationFilter::adjustedSaturation(0.1, 150), 1.0); + QCOMPARE(HueSaturationFilter::adjustedSaturation(0.6, -150), 0.0); + QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.2, 0) - 0.2) < 1e-9); + // A red at saturation 0.2, lightness 0.5, raised by 50. + const HueSaturationFilter::Color color = HueSaturationFilter::adjust(0.6, 0.4, 0.4, HueSaturationSettings(0, 50, 0)); + QVERIFY(std::abs(color.red - 0.7) < 1e-9 && std::abs(color.green - 0.3) < 1e-9 && std::abs(color.blue - 0.3) < 1e-9); +} + QTEST_GUILESS_MAIN(HueSaturationTests) #include "HueSaturationTests.moc" diff --git a/tests/LayerOrderTests.cpp b/tests/LayerOrderTests.cpp new file mode 100644 index 0000000..564c75e --- /dev/null +++ b/tests/LayerOrderTests.cpp @@ -0,0 +1,143 @@ +#include "Document/EditorSession.h" +#include <QRandomGenerator> +#include <QtTest> + +// Swift's LayerOrder: the hierarchy kept until what shapes it changes. +namespace { +// Nested folders, some hidden, built from a fixed seed. +std::vector<ImageLayer> tree(int count) +{ + QRandomGenerator random(11); + std::vector<ImageLayer> layers; + std::vector<QUuid> folders; + for (int index = 0; index < count; ++index) { + ImageLayer layer(QString::number(index), QSizeF(10, 10)); + layer.isGroup = random.bounded(3) == 0; + layer.isVisible = random.bounded(4) != 0; + if (!folders.empty() && random.bounded(3) != 0) + layer.parentID = folders[size_t(random.bounded(int(folders.size())))]; + if (layer.isGroup) + folders.push_back(layer.id); + layers.push_back(layer); + } + return layers; +} + +std::vector<ProjectLayerRecord> records(const std::vector<ImageLayer> &layers) +{ + std::vector<ProjectLayerRecord> result; + for (const ImageLayer &layer : layers) + result.push_back(layer.hierarchyRecord()); + return result; +} +} + +class LayerOrderTests : public QObject { + Q_OBJECT +private slots: + void orderVisibilityAndDrawingFollowTheHierarchy(); + void eachThingThatShapesItIsSeen(); + void sixtyFourFoldersDeepAtMost(); + void nothingShownDrawsNothing(); + void opacitiesMultiplyUpKnownFoldersAlone(); +}; + +void LayerOrderTests::orderVisibilityAndDrawingFollowTheHierarchy() +{ + const std::vector<ImageLayer> layers = tree(60); + const LayerOrder::Result result = LayerOrder::resolve(layers); + std::vector<QUuid> order, drawn; + QSet<QUuid> visible; + for (const LayerHierarchy::Entry &entry : LayerHierarchy::entries(records(layers))) { + order.push_back(entry.layer.id); + if (entry.visible) + visible.insert(entry.layer.id); + } + for (const ProjectLayerRecord &record : LayerHierarchy::visibleLayers(records(layers))) + drawn.push_back(record.id); + QCOMPARE(result.order, order); + QCOMPARE(result.visible, visible); + QCOMPARE(result.drawn, drawn); + // Hidden folders and hidden layers both play a part. + QVERIFY(visible.size() > 5 && int(visible.size()) < 50 && drawn.size() > 5 && qsizetype(drawn.size()) < visible.size()); +} + +void LayerOrderTests::eachThingThatShapesItIsSeen() +{ + CanvasDocument document(10, 10); + ImageLayer folder(QStringLiteral("Folder"), document.size()); + folder.isGroup = true; + ImageLayer inside(QStringLiteral("Inside"), document.size()); + inside.parentID = folder.id; + ImageLayer top(QStringLiteral("Top"), document.size()); + document.layers = {folder, inside, top}; + QCOMPARE(document.hierarchy().drawn, (std::vector<QUuid>{inside.id, top.id})); + // A hidden folder hides what it holds. + document.layers[0].isVisible = false; + QCOMPARE(document.hierarchy().drawn, std::vector<QUuid>{top.id}); + QCOMPARE(document.effectiveVisibleIDs(), QSet<QUuid>{top.id}); + document.layers[0].isVisible = true; + // Out of the folder: the order changes. + document.layers[1].parentID = std::nullopt; + QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, inside.id, top.id})); + document.layers[1].parentID = top.id; + document.layers[2].isGroup = true; + QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, top.id, inside.id})); + QCOMPARE(document.hierarchy().drawn, std::vector<QUuid>{inside.id}); + // A different layer of the same shape: its own id. + document.layers[2].id = QUuid::createUuid(); + document.layers[1].parentID = document.layers[2].id; + QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, document.layers[2].id, inside.id})); +} + +void LayerOrderTests::sixtyFourFoldersDeepAtMost() +{ + std::vector<ImageLayer> layers; + for (int index = 0; index < 70; ++index) { + ImageLayer layer(QString::number(index), QSizeF(10, 10)); + layer.isGroup = index < 69; + if (index > 0) + layer.parentID = layers.back().id; + layers.push_back(layer); + } + const LayerOrder::Result result = LayerOrder::resolve(layers); + QCOMPARE(int(result.order.size()), 65); + QVERIFY(result.drawn.empty()); +} + +void LayerOrderTests::nothingShownDrawsNothing() +{ + CanvasDocument document(10, 10); + ImageLayer hidden(QStringLiteral("Hidden"), document.size()); + hidden.isVisible = false; + // Two of one id: drawn, that throws; hidden, nothing draws. + document.layers = {hidden, hidden}; + QVERIFY(document.renderLayers().empty()); + document.layers[1].isVisible = true; + QVERIFY_THROWS_EXCEPTION(std::logic_error, document.renderLayers()); +} + +void LayerOrderTests::opacitiesMultiplyUpKnownFoldersAlone() +{ + CanvasDocument document(10, 10); + ImageLayer folder(QStringLiteral("Folder"), document.size()); + folder.isGroup = true; + folder.opacity = 0.5; + ImageLayer inside(QStringLiteral("Inside"), document.size()); + inside.parentID = folder.id; + inside.opacity = 0.8; + // A folder nobody holds: the walk stops at its own. + ImageLayer stray(QStringLiteral("Stray"), document.size()); + stray.parentID = QUuid::createUuid(); + stray.opacity = 0.6; + document.layers = {folder, inside, stray}; + const QHash<QUuid, double> opacities = document.effectiveOpacities(); + QCOMPARE(opacities.value(folder.id), 0.5); + QCOMPARE(opacities.value(inside.id), 0.4); + QCOMPARE(opacities.value(stray.id), 0.6); + document.layers.push_back(inside); + QVERIFY_THROWS_EXCEPTION(std::logic_error, document.effectiveOpacities()); +} + +QTEST_GUILESS_MAIN(LayerOrderTests) +#include "LayerOrderTests.moc" diff --git a/tests/PSDAdjustmentTests.cpp b/tests/PSDAdjustmentTests.cpp new file mode 100644 index 0000000..cb96e51 --- /dev/null +++ b/tests/PSDAdjustmentTests.cpp @@ -0,0 +1,155 @@ +#include "Document/BrushStroke.h" +#include "IO/PSD/PSDDocumentBuilder.h" +#include "IO/PSD/PSDReader.h" +#include <QtTest> + +// Swift's PSDAdjustmentTests: adjustments and masks as Photoshop writes them. +namespace { +QByteArray shorts(const std::vector<int> &values) +{ + QByteArray data; + for (const int value : values) { + const quint16 bits = quint16(qint16(value)); + data.append(char(bits >> 8)).append(char(bits & 0xFF)); + } + return data; +} + +// A white patch on a gray grid, as text rows. +QStringList rows(const QImage &mask) +{ + QStringList result; + for (int y = 0; y < mask.height(); ++y) { + QString row; + for (int x = 0; x < mask.width(); ++x) + row += mask.constScanLine(y)[x] > 127 ? u'#' : u'.'; + result << row; + } + return result; +} + +QImage whitePatch() +{ + QImage patch = BrushRaster::context(2, 2, true); + patch.fill(255); + return patch; +} +} + +class PSDAdjustmentTests : public QObject { + Q_OBJECT +private slots: + void levelsGammaIsInHundredths(); + void hueSaturationReadsMasterAndEachRange(); + void maskPatchSitsWhereItIsOnTheCanvas(); + void maskPatchFollowsAPixelLayersGrid(); +}; + +void PSDAdjustmentTests::levelsGammaIsInHundredths() +{ + // RGB 2–254 at gamma 2.50, the rest untouched; 29 records. + std::vector<int> values{2, 2, 254, 0, 255, 250}; + for (int channel = 0; channel < 3; ++channel) + values.insert(values.end(), {0, 255, 0, 255, 100}); + QByteArray data = shorts(values); + data.append(QByteArray(292 - data.size(), 0)); + const LevelsSettings levels = PSDAdjustments::levels(data).value().levels; + QVERIFY(levels.ranges[0] == (LevelRange{.black = 2, .gamma = 2.5, .white = 254, .outputBlack = 0, .outputWhite = 255})); + for (size_t channel = 1; channel < 4; ++channel) + QCOMPARE(levels.ranges[channel].gamma, 1.0); + QVERIFY(!PSDAdjustments::levels(data.left(291))); +} + +void PSDAdjustmentTests::hueSaturationReadsMasterAndEachRange() +{ + // Version 2, Colorize off; its values, the Master's, the Reds'. + QByteArray data = shorts({2}) + QByteArray(2, 0) + shorts({23, 25, 0, 5, 4, 0, 315, 345, 15, 45, 0, -30, 10}); + // Yellows' band wraps: −30 reads as 330. + data += shorts({-30, 15, 45, 75, 7, 8, 9}); + data += shorts(std::vector<int>(4 * 7, 0)); + const HueSaturationSettings settings = PSDAdjustments::hue(data).value().hsvSettings.value(); + QVERIFY(!settings.colorize); + QVERIFY(settings.adjustments.at(ColorRange::master) == (RangeAdjustment{5, 4, 0})); + QVERIFY(settings.adjustments.at(ColorRange::reds) == (RangeAdjustment{0, -30, 10})); + QVERIFY(settings.bands.at(ColorRange::reds) == (HueBand{315, 345, 15, 45})); + QVERIFY(settings.bands.at(ColorRange::yellows) == (HueBand{330, 15, 45, 75})); + QVERIFY(settings.adjustments.at(ColorRange::yellows) == (RangeAdjustment{7, 8, 9})); + QVERIFY(settings.adjustments.at(ColorRange::magentas) == RangeAdjustment()); + + // Colorize on: its own values apply, and nothing else. + data[2] = 1; + const HueSaturationSettings colorized = PSDAdjustments::hue(data).value().hsvSettings.value(); + QVERIFY(colorized.colorize && colorized.adjustments.at(ColorRange::master) == (RangeAdjustment{23, 25, 0})); + QVERIFY(!colorized.adjustments.contains(ColorRange::reds)); + QVERIFY(colorized.bands.at(ColorRange::reds) == defaultBand(ColorRange::reds)); + + // Cut short: the ranges that fit; under 16 bytes, none. + data[2] = 0; + const HueSaturationSettings cut = PSDAdjustments::hue(data.left(16 + 14 + 13)).value().hsvSettings.value(); + QVERIFY(cut.adjustments.at(ColorRange::reds) == (RangeAdjustment{0, -30, 10})); + QVERIFY(!cut.adjustments.contains(ColorRange::yellows)); + QVERIFY(!PSDAdjustments::hue(data.left(15))); +} + +void PSDAdjustmentTests::maskPatchSitsWhereItIsOnTheCanvas() +{ + // A white patch at (3, 1), black round it. + PSDRecord record; + record.id = QUuid::createUuid(); + record.maskBounds = QRectF(3, 1, 2, 2); + record.maskDefault = 0; + const ImageLayer layer(QStringLiteral("Levels"), QSizeF(6, 4)); + const QImage mask = PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value(); + QCOMPARE(mask.size(), QSize(6, 4)); + QCOMPARE(rows(mask), QStringList({"......", "...##.", "...##.", "......"})); + + // Through the builder, the patch at the bottom left. + record.mask = whitePatch(); + record.maskBounds = QRectF(0, 2, 2, 2); + record.adjustment = LayerAdjustment{.kind = AdjustmentKind::levels}; + record.kind = PSDLayerKind::adjustment; + const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{6, 4, 72, {record}}); + QCOMPARE(rows(imported.layers.at(0).mask.value().asset.image()), QStringList({"......", "......", "##....", "##...."})); + QCOMPARE(imported.conversions.size(), size_t(1)); + QCOMPARE(imported.conversions[0].message, QStringLiteral("Adjustment parameters may not match Photoshop exactly.")); +} + +void PSDAdjustmentTests::maskPatchFollowsAPixelLayersGrid() +{ + QImage pixels = BrushRaster::context(4, 2, false); + pixels.fill(Qt::red); + ImageLayer layer(ImportedImage(pixels, pixels, QStringLiteral("Red")), QPointF(2, 1)); + PSDRecord record; + record.maskBounds = QRectF(3, 1, 2, 2); + record.maskDefault = 0; + QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value()), QStringList({".##.", ".##."})); + // A layer stretched twice as wide: the patch is too. + layer.transform.size = QSizeF(2, 2); + QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value()), QStringList({"..##", "..##"})); + // Already on the grid: the patch itself. + QImage grid = BrushRaster::context(4, 2, true); + record.maskBounds = QRectF(2, 1, 2, 2); + QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(grid, record, layer, QSizeF(6, 4)).value().cacheKey(), grid.cacheKey()); + // No bounds, or a sizeless layer: the patch stays. + record.maskBounds = QRectF(2, 1, 0, 2); + QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value().size(), QSize(2, 2)); + record.maskBounds = QRectF(2, 1, 2, 0); + QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value().size(), QSize(2, 2)); + record.maskBounds = QRectF(2, 1, 2, 2); + for (const QSizeF size : {QSizeF(0, 2), QSizeF(2, 0)}) { + layer.transform.size = size; + QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value().size(), QSize(2, 2)); + } + // The grid's bounds, a smaller patch: drawn up to it. + layer.transform.size = QSizeF(4, 2); + record.maskBounds = QRectF(2, 1, 4, 2); + QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value()), QStringList({"####", "####"})); + // The grid's size, a pixel along: placed there. + QImage wide = BrushRaster::context(4, 2, true); + wide.fill(255); + record.maskBounds = QRectF(3, 1, 4, 2); + QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(wide, record, layer, QSizeF(6, 4)).value()), QStringList({".###", ".###"})); +} + +QTEST_GUILESS_MAIN(PSDAdjustmentTests) +#include "PSDAdjustmentTests.moc" diff --git a/tests/PSDReaderFixtures.h b/tests/PSDReaderFixtures.h index 515c624..1ffcca3 100644 --- a/tests/PSDReaderFixtures.h +++ b/tests/PSDReaderFixtures.h @@ -21,6 +21,7 @@ struct Record { uchar flags = 0; std::optional<QRect> mask; uchar maskFlags = 0; + uchar maskDefault = 255; QByteArray name = "Layer"; std::vector<Extra> extras; }; @@ -81,7 +82,7 @@ inline QByteArray file(const std::vector<Record> &records, const QByteArray &res extra.i32(record.mask->left()); extra.i32(record.mask->top() + record.mask->height()); extra.i32(record.mask->left() + record.mask->width()); - extra.u8(255); + extra.u8(record.maskDefault); extra.u8(record.maskFlags); extra.u16(0); } else { diff --git a/tests/PSDReaderTests.cpp b/tests/PSDReaderTests.cpp index 293b5ec..0643dab 100644 --- a/tests/PSDReaderTests.cpp +++ b/tests/PSDReaderTests.cpp @@ -167,6 +167,7 @@ void PSDReaderTests::maskFlagsAndMasksFromRenderedPixels() Record record = pixels(); record.mask = QRect(1, 1, 3, 1); record.maskFlags = flags; + record.maskDefault = flags == 1 ? 0 : 255; record.channels.push_back({-2, QByteArray("\0\0\x10\x20\x30", 5)}); records.push_back(record); } @@ -178,12 +179,22 @@ void PSDReaderTests::maskFlagsAndMasksFromRenderedPixels() QVERIFY(document.layers[1].maskEnabled && !document.layers[1].maskLinked); QVERIFY(!document.layers[2].maskEnabled && document.layers[2].maskLinked); QVERIFY(!document.layers[3].mask); + QCOMPARE(document.layers[0].maskBounds, QRectF(1, 1, 3, 1)); + QVERIFY(document.layers[0].maskDefault == 255 && document.layers[1].maskDefault == 0); const PSDImport imported = PSDDocumentBuilder::makeImport(document); + // On the layer's grid, the default round it. + const QImage placed = imported.layers[0].mask.value().asset.image(); + QCOMPARE(placed.size(), QSize(2, 2)); + QVERIFY(placed.constScanLine(0)[0] == 255 && placed.constScanLine(0)[1] == 255); + QVERIFY(placed.constScanLine(1)[0] == 255 && placed.constScanLine(1)[1] == 0x10); + QCOMPARE(imported.layers[1].mask.value().asset.image().constScanLine(1)[0], uchar(0)); QVERIFY(imported.layers[1].mask.value().isEnabled && !imported.layers[1].mask.value().isLinked); QVERIFY(!imported.layers[2].mask.value().isEnabled); // A mask that is not gray is reported, left off. PSDDocument coloured = document; - coloured.layers[0].mask = PSDFixture::colorImage(3, 1, 1, 1, 1); + // Already the layer's grid, so nothing converts it. + coloured.layers[0].mask = PSDFixture::colorImage(2, 2, 1, 1, 1); + coloured.layers[0].maskBounds = QRectF(0, 0, 2, 2); const PSDImport skipped = PSDDocumentBuilder::makeImport(coloured); QVERIFY(!skipped.layers[0].mask); QCOMPARE(messages(skipped), std::vector<QString>{"Layer: The layer mask couldn’t be converted to 8-bit grayscale and was skipped."}); @@ -317,8 +328,8 @@ void PSDReaderTests::levelsCurvesAndHueParse() levels.bytes(QByteArray(292 - levels.data.size(), '\0')); const LayerAdjustment level = PSDAdjustments::parse({{"levl", levels.data}}).value(); QVERIFY(level.kind == AdjustmentKind::levels); - QCOMPARE(level.levels.ranges[0], (LevelRange{10, 2, 240, 5, 250})); - QCOMPARE(level.levels.ranges[3], (LevelRange{13, 515 / 256.0, 243, 8, 253})); + QCOMPARE(level.levels.ranges[0], (LevelRange{10, 5.12, 240, 5, 250})); + QCOMPARE(level.levels.ranges[3], (LevelRange{13, 5.15, 243, 8, 253})); QVERIFY(!PSDAdjustments::parse({{"levl", levels.data.left(291)}})); PSDFixture::Buffer curves; curves.u8(0); @@ -354,12 +365,12 @@ void PSDReaderTests::levelsCurvesAndHueParse() QVERIFY(shift.kind == AdjustmentKind::hsv); const HueSaturationSettings settings = shift.hsvSettings.value(); QVERIFY(settings.colorize); + // Colorize's own values; the Master's go unread. QCOMPARE(settings.adjustments.at(ColorRange::master), (RangeAdjustment{30, -20, 10})); - QCOMPARE(settings.adjustments.at(ColorRange::reds), (RangeAdjustment{-5, 40, 0})); - QVERIFY(!settings.adjustments.contains(ColorRange::yellows)); + QVERIFY(!settings.adjustments.contains(ColorRange::reds)); // `hue2` comes before `hue `. PSDFixture::Buffer plain; - plain.u32(0); + plain.bytes(QByteArray(16, '\0')); QVERIFY(!PSDAdjustments::parse({{"hue2", plain.data}, {"hue ", hue.data}}).value().hsvSettings.value().colorize); QVERIFY(!PSDAdjustments::parse({{"hue ", QByteArray("\0\0\1", 3)}})); QVERIFY(!PSDAdjustments::parse({{"expA", hue.data}})); diff --git a/tests/SharpCanvasTests.cpp b/tests/SharpCanvasTests.cpp new file mode 100644 index 0000000..6611d5e --- /dev/null +++ b/tests/SharpCanvasTests.cpp @@ -0,0 +1,81 @@ +#include "Document/BrushStroke.h" +#include "Rendering/TiledLayerRenderer.h" +#include <QPainter> +#include <QtTest> + +// Compositor 1.4's sharper canvas: upright layers at 1:1 copy across. +namespace { +// Black and white columns, opaque, 100 by 4. +QImage columns() +{ + QImage image = BrushRaster::context(100, 4, false); + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 100; ++x) + image.setPixel(x, y, x % 2 ? qRgba(255, 255, 255, 255) : qRgba(0, 0, 0, 255)); + } + return image; +} + +using Draw = std::function<void(const LayerTransform &, QPainter &)>; + +// Pixels under the layer neither black nor white. +int blurred(double width, const Draw &draw) +{ + QImage surface = BrushRaster::context(120, 4, false); + QPainter painter(&surface); + draw(LayerTransform{.origin = {0, 0}, .size = {width, 4}}, painter); + painter.end(); + int count = 0; + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 100; ++x) + count += qRed(surface.pixel(x, y)) % 255 != 0; + } + return count; +} +} + +class SharpCanvasTests : public QObject { + Q_OBJECT +private slots: + void anUprightLayerPixelForPixelTakesNoFilter(); + void plainMaskedAndTiledLayersCopyAcross(); +}; + +void SharpCanvasTests::anUprightLayerPixelForPixelTakesNoFilter() +{ + for (LayerSampling sampling : {LayerSampling::smooth, LayerSampling::high}) { + QCOMPARE(LayerRenderer::interpolation(sampling, 1.0009, true), InterpolationQuality::none); + QCOMPARE(LayerRenderer::interpolation(sampling, 0.9991, true), InterpolationQuality::none); + QCOMPARE(LayerRenderer::interpolation(sampling, 0.9989, true), InterpolationQuality::low); + QCOMPARE(LayerRenderer::interpolation(sampling, 1, false), InterpolationQuality::low); + } + QCOMPARE(LayerRenderer::interpolation(LayerSampling::high, 1.0011, true), InterpolationQuality::high); + QCOMPARE(LayerRenderer::interpolation(LayerSampling::high, 1.0009, false), InterpolationQuality::high); + QCOMPARE(LayerRenderer::interpolation(LayerSampling::nearest, 1.5, true), InterpolationQuality::none); +} + +void SharpCanvasTests::plainMaskedAndTiledLayersCopyAcross() +{ + const QImage image = columns(); + QImage white = BrushRaster::context(100, 4, true); + white.fill(255); + const auto raster = std::make_shared<const RasterSnapshot>(100, 4, image, QRectF(0, 0, 100, 4), std::vector<BrushPatch>{}); + const std::vector<Draw> draws{ + [&](const LayerTransform &transform, QPainter &painter) { LayerRenderer::draw(image, transform, transform.center(), painter); }, + [&](const LayerTransform &transform, QPainter &painter) { + LayerRenderer::draw(image, transform, transform.center(), painter, {.mask = white}); + }, + [&](const LayerTransform &transform, QPainter &painter) { + TiledLayerRenderer::drawRaster(raster, transform, transform.center(), painter); + }, + }; + for (const Draw &draw : draws) { + // A twentieth of a pixel over 100: within tolerance, crisp. + QCOMPARE(blurred(100.05, draw), 0); + // A fifth: filtered as before. + QVERIFY(blurred(100.2, draw) > 0); + } +} + +QTEST_GUILESS_MAIN(SharpCanvasTests) +#include "SharpCanvasTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 80012e2..2f8288a 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -262,6 +262,12 @@ compositor_test(PSDVectorTests) compositor_test(PSDReaderTests) compositor_test(PSDReaderLayerTests) compositor_test(PSDImportTests) +compositor_test(PSDAdjustmentTests) +compositor_test(SharpCanvasTests) +compositor_test(AdjustmentBandsTests) +compositor_test(LayerOrderTests) +# It checks the bands against one pass of the kernels. +target_include_directories(AdjustmentBandsTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(RawImporterTests) compositor_test(RawDevelopTests) compositor_test(ToolDefaultsTests) From 06843364fe013dc513937b22cef3c5d29d5d7628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= <zhangyanghaha0407@outlook.com> Date: Thu, 1 Oct 2026 17:47:52 -0400 Subject: [PATCH 11/17] Soft Light by Photoshop's formula; stacks, coverage and adjustments in every mode proven (13.9a) --- src/Rendering/LayerRenderer.cpp | 2 - src/Rendering/SeparableBlend.cpp | 6 + src/Rendering/SeparableBlend.h | 2 +- tests/BlendModeTests.cpp | 7 +- tests/BlendingTests.cpp | 220 +++++++++++++++++++++++++++++++ tests/ClippingStackTests.cpp | 38 ++++++ 6 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 tests/BlendingTests.cpp diff --git a/src/Rendering/LayerRenderer.cpp b/src/Rendering/LayerRenderer.cpp index cd7e691..44d8e8f 100644 --- a/src/Rendering/LayerRenderer.cpp +++ b/src/Rendering/LayerRenderer.cpp @@ -20,8 +20,6 @@ QPainter::CompositionMode compositionMode(LayerBlendMode mode) return QPainter::CompositionMode_Screen; case LayerBlendMode::overlay: return QPainter::CompositionMode_Overlay; - case LayerBlendMode::softLight: - return QPainter::CompositionMode_SoftLight; case LayerBlendMode::darken: return QPainter::CompositionMode_Darken; case LayerBlendMode::lighten: diff --git a/src/Rendering/SeparableBlend.cpp b/src/Rendering/SeparableBlend.cpp index d8779ce..1c232be 100644 --- a/src/Rendering/SeparableBlend.cpp +++ b/src/Rendering/SeparableBlend.cpp @@ -1,5 +1,6 @@ #include "Rendering/SeparableBlend.h" #include <algorithm> +#include <cmath> #include <stdexcept> namespace { @@ -22,6 +23,7 @@ float dodge(float backdrop, float source) bool SeparableBlend::needsSurface(LayerBlendMode mode) { switch (mode) { + case LayerBlendMode::softLight: case LayerBlendMode::linearBurn: case LayerBlendMode::linearDodge: case LayerBlendMode::vividLight: @@ -39,6 +41,10 @@ bool SeparableBlend::needsSurface(LayerBlendMode mode) float SeparableBlend::channel(LayerBlendMode mode, float backdrop, float source) { switch (mode) { + case LayerBlendMode::softLight: + // Photoshop's, as Core Image's: the PDF's strays 17 levels. + return source <= 0.5f ? 2 * backdrop * source + backdrop * backdrop * (1 - 2 * source) + : 2 * backdrop * (1 - source) + std::sqrt(backdrop) * (2 * source - 1); case LayerBlendMode::linearBurn: return std::max(0.0f, backdrop + source - 1); case LayerBlendMode::linearDodge: diff --git a/src/Rendering/SeparableBlend.h b/src/Rendering/SeparableBlend.h index a603e01..595b84e 100644 --- a/src/Rendering/SeparableBlend.h +++ b/src/Rendering/SeparableBlend.h @@ -1,7 +1,7 @@ #pragma once #include "Document/LayerAppearance.h" -// Swift's SeparableBlend: channel-by-channel modes QPainter lacks. +// Swift's SeparableBlend: channel-by-channel modes QPainter lacks or differs on. namespace SeparableBlend { // Blended by hand against a surface, Swift's `needsSurface`. bool needsSurface(LayerBlendMode mode); diff --git a/tests/BlendModeTests.cpp b/tests/BlendModeTests.cpp index c2878ef..c9ca8b9 100644 --- a/tests/BlendModeTests.cpp +++ b/tests/BlendModeTests.cpp @@ -70,11 +70,10 @@ Rgb reference(LayerBlendMode mode, const Rgb &backdrop, const Rgb &source) case LayerBlendMode::multiply: result[channel] = b * s; break; case LayerBlendMode::screen: result[channel] = b + s - b * s; break; case LayerBlendMode::overlay: result[channel] = hardLight(s, b); break; - case LayerBlendMode::softLight: { - const double d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : std::sqrt(b); - result[channel] = s <= 0.5 ? b - (1 - 2 * s) * b * (1 - b) : b + (2 * s - 1) * (d - b); + // Photoshop's, as Core Image computes it (Compositor 1.4). + case LayerBlendMode::softLight: + result[channel] = s <= 0.5 ? 2 * b * s + b * b * (1 - 2 * s) : 2 * b * (1 - s) + std::sqrt(b) * (2 * s - 1); break; - } case LayerBlendMode::darken: result[channel] = std::min(b, s); break; case LayerBlendMode::lighten: result[channel] = std::max(b, s); break; case LayerBlendMode::difference: result[channel] = std::abs(b - s); break; diff --git a/tests/BlendingTests.cpp b/tests/BlendingTests.cpp new file mode 100644 index 0000000..41d1944 --- /dev/null +++ b/tests/BlendingTests.cpp @@ -0,0 +1,220 @@ +#include "Rendering/EditorCanvas.h" +#include "IO/ImageExporter.h" +#include "RenderFixtures.h" +#include <QPainter> +#include <QRadialGradient> +#include <QtTest> + +// Compositor 1.4's blending: stacks, adjustments in a mode, Soft Light. +namespace { +ImportedImage filled(int width, int height, QColor colour, const QString &name) +{ + const QImage image = solid(width, height, colour.rgba()); + return ImportedImage(image, image, name); +} + +// A canvas at 1:1 over the document. +struct Scene { + EditorSession session; + CanvasView canvas{session}; + Scene(int width, int height) + { + session.createDocument(width, height); + session.setShowsTransformControls(false); + canvas.resize(width + 40, height + 40); + session.viewport.resize(QSizeF(width + 40, height + 40), 1, QSizeF(width, height)); + session.zoom(1); + } + QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); } + // Copy Merged of the whole canvas. + QImage merged() + { + session.selectAll(); + const QImage image = session.renderMergedPixels().value().image.convertToFormat(QImage::Format_ARGB32); + session.deselect(); + return image; + } + // The worst channel gap to the export, inside the border. + int canvasGap() + { + const QImage expected = exported(), shot = canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); + const QPointF origin = session.viewport.viewPoint(QPointF(0, 0), session.document().value().size()); + int worst = 0; + for (int y = 1; y < expected.height() - 1; ++y) { + for (int x = 1; x < expected.width() - 1; ++x) { + const QRgb wanted = expected.pixel(x, y), shown = shot.pixel(int(origin.x() + x + 0.5), int(origin.y() + y + 0.5)); + worst = std::max({worst, std::abs(qRed(wanted) - qRed(shown)), std::abs(qGreen(wanted) - qGreen(shown)), + std::abs(qBlue(wanted) - qBlue(shown))}); + } + } + return worst; + } + void mode(LayerBlendMode mode) + { + session.setLayerBlendMode(mode); + } +}; + +std::array<int, 3> rgb(QRgb pixel) +{ + return {qRed(pixel), qGreen(pixel), qBlue(pixel)}; +} + +bool near(QRgb pixel, std::array<int, 3> expected, int tolerance) +{ + const std::array<int, 3> actual = rgb(pixel); + for (int channel = 0; channel < 3; ++channel) { + if (std::abs(actual[size_t(channel)] - expected[size_t(channel)]) > tolerance) + return false; + } + return true; +} + +QString text(QRgb pixel) +{ + return QStringLiteral("(%1, %2, %3)").arg(qRed(pixel)).arg(qGreen(pixel)).arg(qBlue(pixel)); +} +} + +class BlendingTests : public QObject { + Q_OBJECT +private slots: + void clippingStacksBlendInTheirBasesMode_data(); + void clippingStacksBlendInTheirBasesMode(); + void adjustmentsBlendInAnyMode_data(); + void adjustmentsBlendInAnyMode(); + void softLightMatchesPhotoshop(); + void softEdgedLayersBlendTheSame_data(); + void softEdgedLayersBlendTheSame(); +}; + +void BlendingTests::clippingStacksBlendInTheirBasesMode_data() +{ + QTest::addColumn<LayerBlendMode>("mode"); + QTest::newRow("linearDodge") << LayerBlendMode::linearDodge; + QTest::newRow("colorDodge") << LayerBlendMode::colorDodge; + QTest::newRow("multiply") << LayerBlendMode::multiply; +} + +// Swift's: the stack takes its base's mode wherever drawn. +void BlendingTests::clippingStacksBlendInTheirBasesMode() +{ + QFETCH(LayerBlendMode, mode); + Scene scene(100, 100); + EditorSession &session = scene.session; + session.insert(filled(100, 100, QColor::fromRgbF(0.4f, 0.2f, 0.1f), QStringLiteral("Base"))); + session.insert(filled(100, 100, QColor::fromRgbF(0.3f, 0.3f, 0.3f), QStringLiteral("Blended"))); + const QUuid blended = session.activeLayerID().value(); + scene.mode(mode); + session.insert(filled(50, 50, QColor::fromRgbF(0.2f, 0.05f, 0), QStringLiteral("Clipped")), QPointF(25, 25)); + QVERIFY(session.linkMask(blended, session.activeLayerID().value())); + const QImage exported = scene.exported(), merged = scene.merged(); + // Under the clipped layer, and the stack's base alone. + for (const QPoint point : {QPoint(20, 20), QPoint(80, 80)}) + QCOMPARE(text(merged.pixel(point)), text(exported.pixel(point))); + if (mode == LayerBlendMode::linearDodge) { + QVERIFY2(near(exported.pixel(20, 20), {153, 64, 26}, 1), qPrintable(text(exported.pixel(20, 20)))); + QVERIFY2(near(exported.pixel(80, 80), {179, 128, 102}, 1), qPrintable(text(exported.pixel(80, 80)))); + } + QVERIFY(text(exported.pixel(20, 20)) != text(exported.pixel(80, 80))); + QVERIFY(scene.canvasGap() <= 1); +} + +void BlendingTests::adjustmentsBlendInAnyMode_data() +{ + QTest::addColumn<LayerBlendMode>("mode"); + QTest::addColumn<bool>("clipped"); + QTest::addColumn<QList<int>>("expected"); + // (0.2, 0.4, 0.6) inverted is (0.8, 0.6, 0.4). + QTest::newRow("difference") << LayerBlendMode::difference << false << QList<int>{153, 51, 51}; + QTest::newRow("linearLight") << LayerBlendMode::linearLight << false << QList<int>{204, 153, 102}; + QTest::newRow("vividLight in a stack") << LayerBlendMode::vividLight << true << QList<int>{128, 128, 128}; + QTest::newRow("subtract in a stack") << LayerBlendMode::subtract << true << QList<int>{0, 0, 51}; +} + +// Blended at full coverage, alpha restored, then mixed by opacity. +void BlendingTests::adjustmentsBlendInAnyMode() +{ + QFETCH(LayerBlendMode, mode); + QFETCH(bool, clipped); + QFETCH(QList<int>, expected); + Scene scene(40, 30); + EditorSession &session = scene.session; + session.insert(filled(40, 30, QColor::fromRgbF(0.2f, 0.4f, 0.6f), QStringLiteral("Photo"))); + const QUuid photo = session.activeLayerID().value(); + session.addAdjustment(AdjustmentKind::invert); + session.setAdjustmentEditingID(std::nullopt); + const QUuid adjustment = session.activeLayerID().value(); + scene.mode(mode); + if (clipped) + QVERIFY(session.linkMask(photo, adjustment)); + const QImage exported = scene.exported(); + const QRgb shown = exported.pixel(10, 10); + QVERIFY2(near(shown, {expected[0], expected[1], expected[2]}, 1), qPrintable(text(shown))); + QCOMPARE(qAlpha(shown), 255); + QCOMPARE(text(scene.merged().pixel(10, 10)), text(shown)); + QVERIFY(scene.canvasGap() <= 1); + // At half opacity the result mixes halfway back. + session.selectLayer(adjustment); + session.setLayerOpacity(0.5); + const QRgb half = scene.exported().pixel(10, 10); + QVERIFY2(near(half, {(expected[0] + 51) / 2, (expected[1] + 102) / 2, (expected[2] + 153) / 2}, 1), qPrintable(text(half))); +} + +// Photoshop's: 2·b·(1 − s) + √b·(2s − 1) above half. +void BlendingTests::softLightMatchesPhotoshop() +{ + const auto blended = [](double backdrop, double source) { + Scene scene(10, 10); + scene.session.insert(filled(10, 10, QColor::fromRgbF(float(backdrop), float(backdrop), float(backdrop)), QStringLiteral("Base"))); + scene.session.insert(filled(10, 10, QColor::fromRgbF(float(source), float(source), float(source)), QStringLiteral("Soft"))); + scene.mode(LayerBlendMode::softLight); + const int exported = qRed(scene.exported().pixel(5, 5)); + if (qRed(scene.merged().pixel(5, 5)) != exported || scene.canvasGap() > 1) + return -1; + return exported; + }; + // 2·0.5·0.1 + √0.5·0.8 = 0.666: 170. + QCOMPARE(blended(0.5, 0.9), 170); + // Dark: √(5/255) is 0.140, so 36; the PDF gives 19. + QCOMPARE(blended(5 / 255.0, 1), 36); + // Below half, 2bs + b²(1 − 2s): 0.1375 here. + QCOMPARE(blended(0.25, 0.2), 35); +} + +void BlendingTests::softEdgedLayersBlendTheSame_data() +{ + QTest::addColumn<LayerBlendMode>("mode"); + QTest::newRow("softLight") << LayerBlendMode::softLight; + QTest::newRow("overlay") << LayerBlendMode::overlay; + QTest::newRow("multiply") << LayerBlendMode::multiply; + QTest::newRow("colorDodge") << LayerBlendMode::colorDodge; +} + +// A soft brush's layer, mostly partly clear, alike everywhere. +void BlendingTests::softEdgedLayersBlendTheSame() +{ + QFETCH(LayerBlendMode, mode); + Scene scene(60, 50); + const QImage photo = noise(60, 50, 4); + scene.session.insert(ImportedImage(photo, photo, QStringLiteral("Photo"))); + QImage soft(28, 18, QImage::Format_RGBA8888_Premultiplied); + soft.fill(Qt::transparent); + { + QPainter painter(&soft); + QRadialGradient gradient(QPointF(14, 9), 11); + gradient.setColorAt(0.3, QColor::fromRgbF(1, 0.05f, 0, 1)); + gradient.setColorAt(1, QColor::fromRgbF(1, 0.05f, 0, 0)); + painter.fillRect(soft.rect(), gradient); + } + scene.session.insert(ImportedImage(soft, soft, QStringLiteral("Paint")), QPointF(30, 23)); + scene.mode(mode); + const QImage exported = scene.exported(); + // Partly clear pixels blend, as do the opaque ones. + QVERIFY(exported.pixel(30, 23) != photo.pixel(30, 23) && exported.pixel(23, 23) != photo.pixel(23, 23)); + QCOMPARE(scene.merged(), exported); + QVERIFY(scene.canvasGap() <= 1); +} + +QTEST_MAIN(BlendingTests) +#include "BlendingTests.moc" diff --git a/tests/ClippingStackTests.cpp b/tests/ClippingStackTests.cpp index e152709..3e223b9 100644 --- a/tests/ClippingStackTests.cpp +++ b/tests/ClippingStackTests.cpp @@ -10,6 +10,7 @@ private slots: void aStackBlendsOnceWithItsBasesMode(); void laterChildrenCoverEarlierOnes(); void aStackInheritsThePaintersOpacity(); + void stacksAndCoverageTakeTheDevicesPixels(); }; void ClippingStackTests::init() @@ -138,5 +139,42 @@ void ClippingStackTests::aStackInheritsThePaintersOpacity() QCOMPARE(reds(surface), (std::array<int, 4>{127, 64, 16, 0})); } +// Compositor 1.4: surfaces in device pixels keep half units sharp. +void ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels() +{ + Scene scene; + Layer white = placed(solid(4, 2, qRgba(255, 255, 255, 255))); + white.transform = placedAt({0, 0}, {4, 2}); + Layer stripe = placed(solid(1, 2, qRgba(255, 0, 0, 255))); + stripe.transform = placedAt({0.5, 0}, {1, 2}); + stripe.transform.sampling = LayerSampling::nearest; + const QUuid base = scene.add(white); + const QUuid child = scene.add(stripe); + scene.layers[child].source = base; + const auto row = [&](const std::vector<QUuid> &ids) { + LiveMaskRenderer live = scene.renderer(); + live.prepareStacks(ids, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; }); + QImage surface = BrushRaster::context(8, 4, false); + QPainter painter(&surface); + painter.scale(2, 2); + for (const QUuid &id : ids) + live.drawComposite(id, painter); + painter.end(); + QList<QRgb> pixels; + for (int x = 0; x < 4; ++x) + pixels << surface.pixel(x, 1); + return pixels; + }; + const QRgb red = qRgba(255, 0, 0, 255), clear = qRgba(0, 0, 0, 0), whiteness = qRgba(255, 255, 255, 255); + QCOMPARE(row({base, child}), (QList<QRgb>{whiteness, red, red, whiteness})); + // Coverage from a source further down: a layer between. + const QUuid between = scene.add(placed(solid(2, 2, qRgba(0, 0, 0, 0)))); + const QUuid target = scene.add(white); + scene.layers[child].source = std::nullopt; + scene.layers[child].blendMode = LayerBlendMode::normal; + scene.layers[target].source = child; + QCOMPARE(row({child, between, target}), (QList<QRgb>{clear, whiteness, whiteness, clear})); +} + QTEST_MAIN(ClippingStackTests) #include "ClippingStackTests.moc" From d2c8507fc7cc24b444afef37ea221d97ea73f0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= <zhangyanghaha0407@outlook.com> Date: Thu, 1 Oct 2026 17:47:52 -0400 Subject: [PATCH 12/17] Grain and Add Noise stay with the document on the canvas; the GPU canvas's contracts judged and pinned on the CPU canvas (13.9b) --- src/Rendering/EditorCanvas.cpp | 4 + src/Rendering/LiveMaskRenderer.cpp | 7 +- src/Rendering/LiveMaskRenderer.h | 2 + tests/AdjustmentCanvasTests.cpp | 123 ++++++++++++++++++++ tests/CanvasContractFixtures.h | 89 +++++++++++++++ tests/CanvasDisplayTests.cpp | 28 ----- tests/CanvasEditContractTests.cpp | 177 +++++++++++++++++++++++++++++ tests/Tests.cmake | 3 + 8 files changed, 402 insertions(+), 31 deletions(-) create mode 100644 tests/AdjustmentCanvasTests.cpp create mode 100644 tests/CanvasContractFixtures.h create mode 100644 tests/CanvasEditContractTests.cpp diff --git a/src/Rendering/EditorCanvas.cpp b/src/Rendering/EditorCanvas.cpp index c9bb0a1..1f939eb 100644 --- a/src/Rendering/EditorCanvas.cpp +++ b/src/Rendering/EditorCanvas.cpp @@ -430,6 +430,10 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; }; live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).effectiveOpacity(byID); }; live.adjustmentScale = scale; + const QPointF corner = center(QPointF(0, 0)); + live.adjustmentRegion = [corner, scale](const QRectF &rect) { + return QRectF((rect.left() - corner.x()) / scale, (rect.top() - corner.y()) / scale, rect.width() / scale, rect.height() / scale); + }; live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) { const ImageLayer &layer = byID.at(id); if (!layer.mask || !layer.mask->isEnabled) diff --git a/src/Rendering/LiveMaskRenderer.cpp b/src/Rendering/LiveMaskRenderer.cpp index 9bfff0f..f735ca3 100644 --- a/src/Rendering/LiveMaskRenderer.cpp +++ b/src/Rendering/LiveMaskRenderer.cpp @@ -208,8 +208,9 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip) qCWarning(lcRendering) << "an adjustment passes its pixel budget:" << device->size(); return; } - // The document the device shows, where Grain's pattern sits. - const QRectF region = context.deviceTransform().inverted().mapRect(QRectF(device->rect())); + // What the device shows; Grain and Noise keep the document. + const QRectF shown = context.deviceTransform().inverted().mapRect(QRectF(device->rect())); + const QRectF region = adjustmentRegion ? adjustmentRegion(shown) : shown; try { // The kernels' byte order, one image pixel a device pixel. QImage original = device->convertToFormat(QImage::Format_RGBA8888_Premultiplied).copy(); @@ -217,7 +218,7 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip) throw ExportError(ExportError::Kind::render); original.setDevicePixelRatio(1); // Device pixels a document pixel, where Swift's surface counts points. - QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / region.width()); + QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / shown.width()); const auto found = m_modes.find(id); const LayerBlendMode mode = found != m_modes.end() ? found->second : LayerBlendMode::normal; if (mode != LayerBlendMode::normal) diff --git a/src/Rendering/LiveMaskRenderer.h b/src/Rendering/LiveMaskRenderer.h index 6bbafc8..312a348 100644 --- a/src/Rendering/LiveMaskRenderer.h +++ b/src/Rendering/LiveMaskRenderer.h @@ -31,6 +31,8 @@ public: std::function<void(QUuid, const QPainter &, QImage &coverage)> adjustmentClip = [](QUuid, const QPainter &, QImage &) {}; // Painter units a document pixel: the canvas's zoom. double adjustmentScale = 1; + // The document a painter region shows: the canvas maps it. + std::function<QRectF(const QRectF &)> adjustmentRegion; private: // The pixels beneath, adjusted and mixed back through coverage. diff --git a/tests/AdjustmentCanvasTests.cpp b/tests/AdjustmentCanvasTests.cpp new file mode 100644 index 0000000..420edc9 --- /dev/null +++ b/tests/AdjustmentCanvasTests.cpp @@ -0,0 +1,123 @@ +#include "CanvasContractFixtures.h" + +// Compositor 1.4's GPU canvas contracts on the CPU canvas: adjustments. +namespace { +// A canvas over a photo and a smaller, translucent layer. +struct Scene : ContractScene { + Scene(int width, int height, int canvasWidth, int canvasHeight) : ContractScene(width, height, canvasWidth, canvasHeight) + { + const QImage photo = noise(width, height, 3); + session.insert(ImportedImage(photo, photo, QStringLiteral("Photo"))); + const QImage top = noise(width / 2, height / 2, 5, 160); + session.insert(ImportedImage(top, top, QStringLiteral("Top"))); + } + // An adjustment layer of this kind on top, at 80%. + QUuid adjust(const LayerAdjustment &adjustment) + { + session.addAdjustment(adjustment.kind); + session.setAdjustmentEditingID(std::nullopt); + const QUuid id = session.activeLayerID().value(); + session.updateAdjustment(id, adjustment); + session.setLayerOpacity(0.8); + return id; + } +}; + +LayerAdjustment visible(AdjustmentKind kind) +{ + LayerAdjustment adjustment{kind}; + switch (kind) { + case AdjustmentKind::hsv: adjustment.hsvSettings = HueSaturationSettings(120, 30); break; + case AdjustmentKind::levels: adjustment.levels.ranges[0].outputWhite = 160; break; + case AdjustmentKind::curves: adjustment.curves.channels[0] = {{0, 20}, {120, 170}, {255, 235}}; break; + case AdjustmentKind::exposure: adjustment.setExposure(ExposureSettings{.exposure = 0.8, .gamma = 1.2}); break; + case AdjustmentKind::colorBalance: adjustment.setColorBalance(ColorBalanceSettings{.shadowYellowBlue = -30, .midCyanRed = 40}); break; + case AdjustmentKind::gaussianBlur: adjustment.setGaussianRadius(6); break; + case AdjustmentKind::motionBlur: + adjustment.setResolvedMotionDistance(30); + adjustment.setResolvedMotionAngle(30); + break; + case AdjustmentKind::addNoise: + adjustment.setResolvedNoiseAmount(25); + adjustment.setResolvedNoiseSeed(7); + break; + case AdjustmentKind::grain: adjustment.setGrain(GrainSettings{.amount = 60, .size = 3, .seed = 11}); break; + default: break; + } + return adjustment; +} + +} + +class AdjustmentCanvasTests : public QObject { + Q_OBJECT +private slots: + void everyAdjustmentShowsAsTheExport_data(); + void everyAdjustmentShowsAsTheExport(); + void patternsStayWithTheDocument_data(); + void patternsStayWithTheDocument(); +}; + +void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport_data() +{ + QTest::addColumn<AdjustmentKind>("kind"); + for (const AdjustmentKind kind : allAdjustmentKinds) + QTest::newRow(qPrintable(rawValue(kind))) << kind; +} + +// Swift's matchesEveryAdjustment: a mask, 80%, one to one. +void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport() +{ + QFETCH(AdjustmentKind, kind); + Scene scene(60, 50, 60, 50); + const QImage before = scene.exported(); + const QUuid id = scene.adjust(visible(kind)); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, rampMask(60, 50)); }); + scene.session.zoom(1); + QVERIFY(scene.exported() != before); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport()))); +} + +void AdjustmentCanvasTests::patternsStayWithTheDocument_data() +{ + QTest::addColumn<AdjustmentKind>("kind"); + QTest::newRow("addNoise") << AdjustmentKind::addNoise; + QTest::newRow("grain") << AdjustmentKind::grain; +} + +// Swift's patternsStayWithTheDocument: wherever it sits, however zoomed. +void AdjustmentCanvasTests::patternsStayWithTheDocument() +{ + QFETCH(AdjustmentKind, kind); + Scene scene(60, 50, 140, 120); + scene.adjust(visible(kind)); + // One to one, and the crisp path's document pixels. + for (const double zoom : {1.0, 2.0}) { + scene.session.zoom(zoom); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1: %2").arg(zoom).arg(scene.gapToExport()))); + scene.session.viewport.translate(QSizeF(3, -2)); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1 panned: %2").arg(zoom).arg(scene.gapToExport()))); + } + // Reduced: a whole-pixel pan moves the pattern with the image. + scene.session.zoom(0.8); + const QImage before = scene.shot(); + const QPoint corner = scene.origin().toPoint(); + scene.session.viewport.translate(QSizeF(5, 3)); + const QImage after = scene.shot(); + QCOMPARE(scene.origin().toPoint(), corner + QPoint(5, 3)); + int worst = 0; + for (int y = corner.y() + 2; y < corner.y() + 36; ++y) { + for (int x = corner.x() + 2; x < corner.x() + 44; ++x) { + const QRgb was = before.pixel(x, y), now = after.pixel(x + 5, y + 3); + worst = std::max({worst, std::abs(qRed(was) - qRed(now)), std::abs(qGreen(was) - qGreen(now)), std::abs(qBlue(was) - qBlue(now))}); + } + } + QVERIFY2(worst <= 1, qPrintable(QString::number(worst))); + // A partial repaint shows the whole's pattern, resampled alike. + const QRect part(corner + QPoint(18, 12), QSize(20, 15)); + const QImage piece = scene.canvas.grab(part).toImage().convertToFormat(QImage::Format_ARGB32); + QVERIFY(ContractScene::gap(piece, after.copy(part)) <= 2); +} + +QTEST_MAIN(AdjustmentCanvasTests) +#include "AdjustmentCanvasTests.moc" diff --git a/tests/CanvasContractFixtures.h b/tests/CanvasContractFixtures.h new file mode 100644 index 0000000..ec128dc --- /dev/null +++ b/tests/CanvasContractFixtures.h @@ -0,0 +1,89 @@ +#pragma once +#include "Rendering/EditorCanvas.h" +#include "IO/ImageExporter.h" +#include "RenderFixtures.h" +#include "SessionFixtures.h" +#include <QPainter> +#include <QPainterPath> +#include <cmath> +#include <QtTest> + +// Compositor 1.4's GPU canvas tests, held against the CPU canvas. +struct ContractScene { + EditorSession session; + CanvasView canvas{session}; + ContractScene(int width, int height, int canvasWidth, int canvasHeight) + { + session.createDocument(width, height); + session.setShowsTransformControls(false); + canvas.resize(canvasWidth, canvasHeight); + session.viewport.resize(QSizeF(canvasWidth, canvasHeight), 1, QSizeF(width, height)); + } + QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); } + QPointF origin() { return session.viewport.viewPoint(QPointF(0, 0), session.document().value().size()); } + QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); } + // What the canvas shows at each document pixel's centre. + QImage documentShot() + { + canvas.synchronizeDisplay(); + const QImage view = shot(); + const QSizeF size = session.document().value().size(); + const double zoom = session.viewport.pointsPerPixel(); + QImage result(size.toSize(), QImage::Format_ARGB32); + for (int y = 0; y < result.height(); ++y) { + for (int x = 0; x < result.width(); ++x) + result.setPixel(x, y, view.pixel(int(origin().x() + (x + 0.5) * zoom), int(origin().y() + (y + 0.5) * zoom))); + } + return result; + } + // The export over the checkerboard, as the canvas shows it. + QImage exportedOnCanvas() + { + const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image; + const double zoom = session.viewport.pointsPerPixel(); + QImage result(exported.size(), QImage::Format_ARGB32_Premultiplied); + for (int y = 0; y < result.height(); ++y) { + for (int x = 0; x < result.width(); ++x) { + const bool light = (int(std::floor((x + 0.5) * zoom / 10)) + int(std::floor((y + 0.5) * zoom / 10))) % 2 == 0; + result.setPixel(x, y, QColor::fromRgbF(light ? 0.35 : 0.30, light ? 0.35 : 0.30, light ? 0.35 : 0.30).rgb()); + } + } + QPainter painter(&result); + painter.drawImage(QRectF(result.rect()), exported); + painter.end(); + return result.convertToFormat(QImage::Format_ARGB32); + } + int gapToExport(const QPainterPath &skip = QPainterPath()) { return gap(documentShot(), exportedOnCanvas(), skip); } + // The worst channel gap inside the border, `skip` aside. + static int gap(const QImage &one, const QImage &other, const QPainterPath &skip = QPainterPath()) + { + int worst = 0; + for (int y = 1; y < one.height() - 1; ++y) { + for (int x = 1; x < one.width() - 1; ++x) { + if (skip.contains(QPointF(x + 0.5, y + 0.5))) + continue; + const QRgb a = one.pixel(x, y), b = other.pixel(x, y); + worst = std::max({worst, std::abs(qRed(a) - qRed(b)), std::abs(qGreen(a) - qGreen(b)), std::abs(qBlue(a) - qBlue(b))}); + } + } + return worst; + } + // A band round a path, where overlays draw. + static QPainterPath band(const QPainterPath &path, double width = 6) + { + QPainterPathStroker stroker; + stroker.setWidth(width); + return stroker.createStroke(path); + } +}; + +// A mask dark on the left, light on the right. +inline ImportedImage rampMask(int width, int height) +{ + QImage mask(width, height, QImage::Format_Grayscale8); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) + mask.scanLine(y)[x] = uchar(x * 255 / (width - 1)); + } + return LayerMask::assetFrom(mask); +} diff --git a/tests/CanvasDisplayTests.cpp b/tests/CanvasDisplayTests.cpp index cd237af..2812799 100644 --- a/tests/CanvasDisplayTests.cpp +++ b/tests/CanvasDisplayTests.cpp @@ -83,7 +83,6 @@ private slots: void theLiveCompositeDrawsWhatTheExportDraws(); void adjustmentsShowAsTheExportDoes(); void anAdjustmentsMaskClipsItOnTheCanvas(); - void grainFollowsTheViewOnTheCanvas(); }; void CanvasDisplayTests::theBackgroundCheckerboardShadowAndBorderSurroundTheDocument() @@ -411,32 +410,5 @@ void CanvasDisplayTests::anAdjustmentsMaskClipsItOnTheCanvas() session.cancelBrush(); } -void CanvasDisplayTests::grainFollowsTheViewOnTheCanvas() -{ - // Swift's canvas hands Grain the view's region, not the document's. - Scene scene(40, 40, 120, 120); - EditorSession &session = scene.session; - session.insert(filled(40, 40, qRgba(128, 128, 128, 255), "Gray")); - session.addAdjustment(AdjustmentKind::grain); - session.setAdjustmentEditingID(std::nullopt); - LayerAdjustment grain = session.activeLayer().value().adjustment.value(); - GrainSettings strong = grain.grain(); - strong.amount = 100; - // Pinned: under some seeds the pan keeps one pixel's tone. - strong.seed = 7; - grain.setGrain(strong); - session.updateAdjustment(session.activeLayerID().value(), grain); - session.zoom(1); - QVERIFY(gapToExport(scene) > 20); - // A pan moves the pattern under the same document pixel. - const auto pixel = [&scene] { - const QPointF origin = scene.origin(); - return scene.shot().pixel(int(origin.x() + 20.5), int(origin.y() + 20.5)); - }; - const QRgb before = pixel(); - session.viewport.translate(QSizeF(3, 0)); - QVERIFY(pixel() != before); -} - QTEST_MAIN(CanvasDisplayTests) #include "CanvasDisplayTests.moc" diff --git a/tests/CanvasEditContractTests.cpp b/tests/CanvasEditContractTests.cpp new file mode 100644 index 0000000..23540b0 --- /dev/null +++ b/tests/CanvasEditContractTests.cpp @@ -0,0 +1,177 @@ +#include "CanvasContractFixtures.h" + +// Compositor 1.4's GPU canvas contracts on the CPU canvas: edits. +namespace { +QUuid insertNoise(ContractScene &scene, int width, int height, quint32 seed, QPointF centre, int alpha = 255) +{ + const QImage image = noise(width, height, seed, alpha); + scene.session.insert(ImportedImage(image, image, QStringLiteral("Noise")), centre); + return scene.session.activeLayerID().value(); +} + +QPainterPath ellipse(const QRectF &rect) +{ + QPainterPath path; + path.addEllipse(rect); + return path; +} + +bool finished(const std::function<void(std::function<void()>)> &start) +{ + bool done = false; + start([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 20000); +} +} + +class CanvasEditContractTests : public QObject { + Q_OBJECT +private slots: + void aNewMaskRevealsTheWholeLayer_data(); + void aNewMaskRevealsTheWholeLayer(); + void movingPixelsShowAsTheirCommit_data(); + void movingPixelsShowAsTheirCommit(); + void aGradientInASelectionShowsAsItsCommit(); + void aGradientThatCannotFillCancels(); + void aLiveMaskFromFurtherDownShowsAsTheExport(); +}; + +void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer_data() +{ + QTest::addColumn<double>("rotation"); + QTest::newRow("upright") << 0.0; + QTest::newRow("turned") << 25.0; +} + +// Swift's aNewMaskRevealsTheWholeLayer: one white pixel, stretched. +void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer() +{ + QFETCH(double, rotation); + ContractScene scene(80, 70, 80, 70); + const QUuid id = insertNoise(scene, 30, 20, 2, QPointF(40, 35)); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform = LayerTransform{.origin = {10, 12}, .size = {38, 30}, .rotation = rotation}; + }); + scene.session.zoom(1); + scene.session.selectLayer(id); + const QImage before = scene.exported(); + scene.session.addLayerMask(); + QCOMPARE(scene.session.activeLayer().value().mask.value().asset.size(), QSize(1, 1)); + const QImage after = scene.exported(); + // Wherever the layer covered its pixel, nothing changes. + int covered = 0; + for (int y = 0; y < after.height(); ++y) { + for (int x = 0; x < after.width(); ++x) { + if (qAlpha(before.pixel(x, y)) < 255) + continue; + ++covered; + QVERIFY2(ContractScene::gap(before.copy(x - 1, y - 1, 3, 3), after.copy(x - 1, y - 1, 3, 3)) <= 1, + qPrintable(QStringLiteral("%1, %2").arg(x).arg(y))); + } + } + QVERIFY(covered > 900); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport()))); +} + +void CanvasEditContractTests::movingPixelsShowAsTheirCommit_data() +{ + QTest::addColumn<bool>("duplicate"); + QTest::newRow("moving") << false; + QTest::newRow("duplicating") << true; +} + +// Swift's matchesWhileMovingPixels, on a masked layer at 60%. +void CanvasEditContractTests::movingPixelsShowAsTheirCommit() +{ + QFETCH(bool, duplicate); + ContractScene scene(80, 60, 80, 60); + const QUuid id = insertNoise(scene, 80, 60, 2, QPointF(40, 30)); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { + setMask(snapshot, id, rampMask(80, 60)); + record(snapshot, id).opacity = 0.6; + }); + scene.session.zoom(1); + scene.session.selectLayer(id); + const QImage still = scene.documentShot(); + scene.session.applySelection(ellipse(QRectF(20, 15, 30, 24)), SelectionMode::replace, QStringLiteral("Select")); + QVERIFY(scene.session.beginPixelMove(duplicate)); + scene.session.movePixels(QSizeF(9.4, 5.6)); + const QImage moving = scene.documentShot(); + QVERIFY(finished([&](std::function<void()> done) { scene.session.finishPixelMove(std::move(done)); })); + const QImage moved = scene.documentShot(); + // The ants run round the moved outline: left out. + const QPainterPath ants = ContractScene::band(ellipse(QRectF(29, 21, 30, 24))); + QVERIFY(ContractScene::gap(still, moving, ants) > 20); + QVERIFY2(ContractScene::gap(moving, moved, ants) <= 1, qPrintable(QString::number(ContractScene::gap(moving, moved, ants)))); + QVERIFY(scene.gapToExport(ants) <= 1); + // A move leaves a hole; a duplicate leaves its pixels. + const QRgb left = scene.exported().pixel(24, 27); + QCOMPARE(qAlpha(left) == 0, !duplicate); +} + +// Swift's matchesWhileDraggingAGradient: inside a selection, at 70%. +void CanvasEditContractTests::aGradientInASelectionShowsAsItsCommit() +{ + ContractScene scene(80, 60, 80, 60); + insertNoise(scene, 80, 60, 2, QPointF(40, 30)); + scene.session.zoom(1); + const QImage still = scene.documentShot(); + const QPainterPath outline = ellipse(QRectF(15, 10, 40, 30)); + scene.session.applySelection(outline, SelectionMode::replace, QStringLiteral("Select")); + GradientSettings settings; + settings.opacity = 0.7; + settings.style = GradientStyle::foregroundToBackground; + scene.session.setGradientSettings(settings); + scene.session.selectTool(NavigationTool::gradient); + scene.session.beginGradient(QPointF(10, 30)); + scene.session.moveGradient(std::nullopt, QPointF(70, 30)); + scene.session.endGradientDrag(); + const QImage pending = scene.documentShot(); + QVERIFY(finished([&](std::function<void()> done) { scene.session.commitGradient(std::move(done)); })); + const QImage committed = scene.documentShot(); + // The ants, the gradient's line and its ends: overlays. + QPainterPath line; + line.moveTo(10, 30); + line.lineTo(70, 30); + const QPainterPath overlays = ContractScene::band(outline).united(ContractScene::band(line, 30)); + QVERIFY(ContractScene::gap(still, pending, overlays) > 20); + QVERIFY2(ContractScene::gap(pending, committed, overlays) <= 1, qPrintable(QString::number(ContractScene::gap(pending, committed, overlays)))); + // Outside the selection the photo stays. + QCOMPARE(scene.exported().pixel(3, 3), still.pixel(3, 3)); +} + +// Swift's 5708ed3: a fill that fails cancels with the error. +void CanvasEditContractTests::aGradientThatCannotFillCancels() +{ + ContractScene scene(80, 60, 80, 60); + insertNoise(scene, 80, 60, 2, QPointF(40, 30)); + const QImage before = scene.exported(); + const int count = scene.session.history.undoCount(); + scene.session.selectTool(NavigationTool::gradient); + scene.session.beginGradient(QPointF(10, 30)); + QVERIFY(scene.session.gradientEdit()); + scene.session.gradientEdit()->raster->pixelLimit = 100; + scene.session.moveGradient(std::nullopt, QPointF(70, 30)); + QVERIFY(!scene.session.gradientEdit() && scene.session.brushError().has_value()); + QCOMPARE(scene.session.history.undoCount(), count); + QCOMPARE(scene.exported(), before); +} + +// Swift's matchesALiveMaskOutsideAClippingStack: a turned, distant source. +void CanvasEditContractTests::aLiveMaskFromFurtherDownShowsAsTheExport() +{ + ContractScene scene(80, 60, 80, 60); + insertNoise(scene, 80, 60, 2, QPointF(40, 30)); + const QUuid source = insertNoise(scene, 40, 30, 4, QPointF(35, 30), 140); + rewrite(scene.session, [&](ProjectSnapshot &snapshot) { record(snapshot, source).transform.rotation = 25; }); + insertNoise(scene, 16, 16, 5, QPointF(60, 20)); + const QUuid target = insertNoise(scene, 50, 40, 9, QPointF(40, 30)); + const QImage unlinked = scene.exported(); + QVERIFY(scene.session.linkMask(source, target)); + scene.session.zoom(1); + QVERIFY(ContractScene::gap(unlinked, scene.exported()) > 20); + QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport()))); +} + +QTEST_MAIN(CanvasEditContractTests) +#include "CanvasEditContractTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 2f8288a..e7a3d6b 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -266,6 +266,9 @@ compositor_test(PSDAdjustmentTests) compositor_test(SharpCanvasTests) compositor_test(AdjustmentBandsTests) compositor_test(LayerOrderTests) +compositor_test(BlendingTests) +compositor_test(AdjustmentCanvasTests) +compositor_test(CanvasEditContractTests) # It checks the bands against one pass of the kernels. target_include_directories(AdjustmentBandsTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(RawImporterTests) From d571a090d3813c35fff5c06bc8424a4911e53f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= <zhangyanghaha0407@outlook.com> Date: Thu, 1 Oct 2026 17:51:22 -0400 Subject: [PATCH 13/17] AGENTS and TASKS for 13.8, 13.9a, 13.9b --- AGENTS.md | 7 +++++-- TASKS.md | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5dfcda4..cdb8112 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ Platform adaptations, each forced by a missing Qt analog: - Filters in the session (9.4a): `Document/Filters.h/.cpp` hold Swift's model (Remove Background's kind, its four settings, `SubjectRemoval` and `commitBackgroundMask` since 9.7); `isImageAdjustment` and the list of kinds arrive with the Filter menu (9.4b), their only reader. `FilterSettings::normalized` clamps through `ImageAdjustmentPixels::clamp`, a value that is not finite taking the default. `PixelFilter::run` hands Curves, Exposure, the Gradient Map and Grain to their settings' `apply` (Grain in layer pixels, `1 / scale` units, the job's seed), the fill to `ContentFill::run`, and the rest a premultiplied copy, as Swift's contexts: Gaussian Blur is `PixelAdjust::gaussianBlur` unclamped, clear past the edge as Core Image blurs a finite image; Motion Blur is `PixelAdjust::motionBlur`, `CIMotionBlur` on the CPU: Gaussian taps (exp(−i²/2σ²) over three sigmas, normalised) along the angle at σ = distance × scale / √12, each tap read bilinearly with clear past the edge, rows in parallel, the angle counterclockwise on screen since Core Image's y points up; Add Noise and Lens Correction run Swift's kernels, `NoisePixels.c` and `LensPixels.c`, compiled in place (the lens's k is distortion / 100 × 0.35). Known difference: Swift measured Core Image's taper on a dot; the twin's is the exact Gaussian. `FilterEdit::prepare` is Swift's `prepared` and `prepare` in one: the mapping and a preview copy at most 2048 on its long side (sides truncated, one at least; whole for noise, grain and the fill; a painted layer's copy drawn from its tiles, a full-size one flattened as Levels' is). `growForBlur` pads by the blur's reach (3σ + 2, or half the streak + 2) through `grow`, 30,000 a side and one surface at most (`DocumentLimits`, 200 megapixels since 12.11a; `ProjectError(tooLarge)`), and never shrinks the grid; its rounding up is left out, since `grow` takes the integral box. The session keeps `filterSettings`, which each commit writes and each filter starts from, the Gradient Map from the palette's foreground and background instead. `updateFilter(settings, preview)` normalises, grows the grid when a blur needs more room (a failure is `brushError`; the old preview goes), renders an automatic kind once per settings (`preparedSettings`, which the worker hands back with its image), and with Preview off drops the waiting job and the preview. Known difference: one watcher serves every edit, so a job may wait behind a cancelled edit's run; that job is Swift's running task, and Preview off leaves it (dropped, it would leave `preparing` set for good). `commitFilter` closes a Gradient Map picker with OK first (`cancelFilter` with Cancel), closes as Cancel does with no step when nothing would change (no distortion, Exposure's defaults, no grain), forgets the running preview and drops the waiting job (Swift cancels the task), and hands the worker the job's parts, so a painted layer flattens there; a blur's result is trimmed to the pixels it left, any other keeps the grown grid, and an automatic kind reuses its preview when the settings match. Known difference (a fix): Swift carries a covering mask over the grown box while a trimmed blur's pixels sit on the trimmed box, so the mask slid and squeezed; the twin carries it over the layer's new box. The picker's `gradientMap` target (`ColorPickerTarget::highlights` names the end; the title follows it) opens on the open Gradient Map through `openGradientMapColorPicker`, `previewGradientMapColor` writes the working colour into the map through `updateFilter` (silent when nothing changes), and `closeColorPicker` writes OK's colour or puts the original back, the palette untouched. Left out as dead: the opener's `committing` term (a commit keeps the project busy, which `canEditPalette` reads) and `setGradientMapColor`'s (a commit closes the picker first); that function's other guards came back with 9.5c. `PixelFilter::trimmed` throws `ExportError(render)` when its crop finds no memory, since Swift's `cropping` shares the pixels and cannot fail so; the blurs' row lists (`rowNumbers`) turn `bad_alloc` into `ExportError(render)`, as Hue/Saturation's do, and their weight tables stay plain, the callers bounding them to 1,734 entries. Tests: `FilterTests` (Swift's four; the Gaussian one tests the code, which grows and trims, as Swift's has since 1.2.0, `7c4512a`), `FilterModelTests` (Add Noise and the lens checked against Swift's kernels, whose headers only that test includes), `FilterSessionTests`, `FilterFailureTests` (a binary of its own, under `AddressSpaceLimit`), and in `ImageAdjustmentTests` Swift's two Image-menu tests and the command half of `curvesIdentityAndImageCommandPreserveAlpha` (Swift's `AdjustmentLayerTests` holds it). Equivalent mutant: `>=` in `growForBlur`'s guard (at equal reach it grows the same grid again). - The filter sheet (9.4b): `UI/FilterSheet.h/.cpp` twin Swift's file, `FilterSheet` and `GradientMapControls`. The sheet is the content of a `FloatingPanel` named `filterPanel`, which `ContentView::synchronizePanels` shows with the edit, titled by its kind, and closes with it, its close button and Escape cancelling (Swift's `onChange(of: filterEdit == nil)`). The kind's controls are made once, since an edit's kind never changes. Swift's `control` is a title (sixty points at least, the slider's buddy), a slider over a thousand steps of its range, or of the range's logarithm where Swift's is logarithmic, the value rounded to the field's decimals as Swift's binding rounds it, and a 56-point `PickerField` with its unit two points after (Swift's `unitSuffix`), which applies a readable finite number on Return or on leaving (normalizing clamps it), gives up its typing when its own slider changes the value (a wheel leaves the keys with the field; a notch that rounds to the same value changes nothing; 9.6f), and shows Swift's `.fractionLength(0...decimals)`: at most that many decimals, none trailing, in the field's locale, digits never grouped, as in every field of the twin. The sliders show the session's number under a `QSignalBlocker`, so a dragged thumb sits at the rounded value, as Swift's binding shows it; a step that rounds to the setting it already holds changes nothing, so the thumb keeps its place and arrow steps add up. Add Noise's distribution is two checkable buttons in a `QButtonGroup` beside its label (Swift's segmented picker), Monochromatic a `QCheckBox`; Lens Correction's note is callout-sized in `PlaceholderText`; Curves hosts `CurvesControls` through the settings. `GradientMapControls` draws Swift's gradient bar (20 high, rounded 4, a 35% black rim inside), the ends as the palette's `SwatchButton`s (24 points, radius 6, a white ring of 1.5 inside a black rim) with their titles, help and accessible names, and Reverse; a swatch opens the picker on its end, and on every change of the picker's colour the sheet calls `previewGradientMapColor` (Swift's `onChange`), which ignores a palette picker. The controls wait for the sheet's first synchronize, which runs once they are built. `settings` and `update` keep Swift's fallbacks for a sheet whose edit has gone: the defaults, Preview on. The Image menu gains Curves… (Ctrl+M) before Levels…, and Exposure…, Gradient Map… and Grain… after Hue/Saturation…; the Filter menu (`Fil&ter`, between Image and Layer, as Swift declares it) lists `allFilterKinds` but the fill and the image adjustments (`isImageAdjustment`), Remove Background among them since 9.7; every entry rests while colours cannot be adjusted or Hue/Saturation is open, as Swift's. Tests: `FilterSheetTests` (7.5's, moved to the panel), `FilterControlsTests` (each kind's controls, the sliders' travel, the fields, the Gradient Map's swatches, bar and repaint), the menus' in `SelectMenuTests` and `CompositorMenusTests`. Equivalent mutants: Swift's `?? .gaussianBlur` for the kind (the panel makes a sheet only for an open edit) and the memory of the picker's colour (`previewGradientMapColor` with an unchanged colour changes nothing). - Adjustment layers in the document and the store (9.5a): `Document/LayerAdjustment.h/.cpp` twin Swift's file, `LayerAdjustment+Session.h` its session half. `AdjustmentKind` keeps Swift's raw values (`rawValue`, and `adjustmentKind(text)` for the manifest) and case order (`allAdjustmentKinds`); `filterKind` came with 9.5c, its reader; `symbol` names an SF Symbol, whose glyph the panel draws (9.5d). `LayerAdjustment` keeps Swift's fields: the legacy Hue/Saturation four, the optional `hsvSettings`, Levels and Curves, and the optional Exposure, Gradient Map and Grain, whose computed properties are getter and setter pairs; `resolvedHSV` falls back on the legacy four. `isValid` keeps Swift's bounds (hue within 360 either way, the rest within 100, each range's values alike, every band's handles finite, Levels as normalized, then every kind's own rules whatever the kind) and leaves out the `isFinite` terms the bounds already refuse and Levels' count (the twin holds four ranges). `apply` arrives with 9.5b, its reader. `ImageLayer.adjustment` joins equality and travels wherever Swift lists it: the record (`hierarchyRecord`, so the snapshot), `ImageLayer(record, …)` (so an install and a resize's rebuild), both resizers and duplicates. A copy between projects needs no term of its own: the baker bakes nothing for a layer without pixels, and the placement unlinks it, where Swift skips the bake. The manifest's `adjustment` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Adjustment.cpp` over the readers the manifest shares (`ManifestJson` in `IO/ProjectStore+Json.h`): every key required but the four optionals, which stay out when absent; a `ColorRange`-keyed dictionary is an array of key and value in turn, since Swift's `ColorRange` declares no `CodingKeyRepresentable` (a range Swift does not name refuses it, and so does an odd count, whose last key has no value to read, so Swift's count check has no twin; a later pair wins, as a dictionary assigns); the seed is a `UInt32`. The store refuses an adjustment before version 7, on a folder, with pixels or invalid; Swift's text rule's adjustment term is dead, since text needs pixels. `LiveMaskGraph::validate` refuses clipping to an adjustment layer, so `canLinkMask` refuses one as a source; `canPaint` refuses an adjustment layer's pixels, not its mask. `addAdjustment` puts a blank layer named by its kind just above the active one (into an active folder, uncollapsed; Swift's `addBlankLayer` alone lifts a layer above the folder's contents), as one `New … Adjustment` step, a Gradient Map running from the foreground to the background and each Grain with a seed of its own, 10,000 layers at most, then asks for its editor (Swift's `adjustmentEditingID`, 9.5c). `updateAdjustment` writes valid settings alone, with no step of its own, and moves `brushRevision`. Tests: `LayerAdjustmentTests` (kinds, validity, the optionals, the manifest's keys and refusals, the store's and the graph's rules, adding, updating, travel), `AdjustmentLayerTests` (Swift's `legacyHSVStillDecodesAndRenders`; the rest arrive with 9.5b and 9.5c), Swift's `settingsSaveAndOlderAdjustmentsStillOpen` in `ImageAdjustmentTests`, and an unbaked copy in `ProjectCopyTests`. -- Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. Known difference, Swift's too: the canvas places layers by scale and centre, not through the painter's transform, so its region is the view's and Grain's pattern follows the view, moving under a pan and differing from the export (`grainFollowsTheViewOnTheCanvas`). `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes`, `anAdjustmentsMaskClipsItOnTheCanvas` and `grainFollowsTheViewOnTheCanvas` in `CanvasDisplayTests`. +- Adjustment layers drawn (9.5b): `LayerAdjustment::apply` runs the kind's own filter (Hue/Saturation and Levels through their filters, Curves, Exposure and the Gradient Map through their settings), Grain placed by the region it is given: its origin and its units a pixel, `region.width / image.width`, so the export and `drawLiveComposite` keep the pattern in the document however their drawing is cut or scaled. `LiveMaskRenderer` gains Swift's three hooks as public members: `adjustment`, `adjustmentOpacity` and `adjustmentClip`, which multiplies the layer's own mask into device coverage where Swift clips the context. `prepareStacks` keeps every layer's mode (Swift's `blendMode`); Swift's `adjustment(base) == nil` there is dead and left out, since nothing clips to an adjustment (`LiveMaskGraph`). `drawComposite` hands an unclipped adjustment to `adjust` and draws a clipped one only inside its base's stack, where `adjust` runs on the group after its unpremultiplied base, as Swift's. `adjust` reads the painter's device (a `QImage`, as `LayerRenderer` requires; else a `logic_error`), converted to the kernels' `RGBA8888_Premultiplied` at a pixel ratio of one, within the pixel budget (past it, a warning on `omaphoto.rendering`, where Swift's `try?` is silent), applies the settings over the region the device shows (its device transform inverted), blends a mode other than normal at full coverage and restores the original alpha (`layer_extract_alpha`, `layer_unpremultiply_opaque`, `LayerRenderer::composite` for the mode, `layer_restore_alpha`), then mixes the result into the original by opacity × mask × outer clip, rounded once, where Swift mixes by opacity (`CIBlendWithMask`) and then draws through the soft clip, and draws it back one to one in `Source` mode under the painter's own clip, as Swift's copy honours the context's clip, the painter's whole device transform cancelled as the stack group cancels it, so an image of any format or pixel ratio takes it (the app hands it only `RGBA8888_Premultiplied` at a ratio of one: the export's image, a stack's group, `AdjustmentSurface`'s surface; a caller of the renderer itself may hand it any image). A failure to allocate is `ExportError`, caught there as a warning; anything else is a bug and is left to crash. `ImageExporter::render`, `drawLiveComposite` and the canvas set the hooks as Swift's three places do; `drawLiveComposite` gains Swift's `onSurface` and, when a document holds an adjustment, draws through `AdjustmentSurface`, so a painter's opacity or clip applies to the composite once. The canvas clips an adjustment by its enabled mask, mid-stroke by the edit's tiles (`maskEdit`, shared with the folders' clip, Swift's `liveMaskEdit`), placed by the layer's transform at the view's scale; its `DisplayState` counts adjustment layers with their settings, since an undo restores settings without moving `brushRevision`. Tests: Swift's `AdjustmentLayerTests` (the curves command test moved there from `ImageAdjustmentTests`, its Swift home), `theLiveCompositeDrawsAsTheExport`, `opacityMixesAndGrainSitsInTheDocument`, `everyKindAdjustsThroughItsSettings` and `aFoldersMaskClipsTheAdjustmentsInside` beside them, `anyImageTargetTakesTheAdjustment` beside them, `anAdjustmentAdjustsWithinItsBudgetAndStack` and `anAdjustmentTakesAnyImageTarget` in `LiveMaskRendererTests`, `adjustmentsShowAsTheExportDoes` and `anAdjustmentsMaskClipsItOnTheCanvas` in `CanvasDisplayTests`. - Editing adjustment layers in the session (9.5c): `Document/AdjustmentEditing.cpp` twins Swift's file, `AdjustmentEditing+Session.h` its session half; `adjustmentEditingID` (a getter and a setter that resumes file requests and notifies, Swift's `didSet`) and `adjustmentOriginal` (a getter) sit in `EditorSession.h`. `addAdjustment` asks for the new layer's editor last, as Swift's. `beginAdjustmentEditing` takes a completion callback: it hides every layer not beneath the adjustment in hierarchy order (the adjustment itself and all above; the records stay for live masks and folders), renders that snapshot through `ImageExporter::render` in a worker (`QtConcurrent::run` with a watcher per call, as each Swift call is a task of its own; the worker catches `ExportError` alone, since a session's snapshot never fails the store's rules), and on the UI thread opens the kind's editor on a layer of that image at the origin: Levels with its histogram (`countLevelsHistogram`, which `beginLevels` shares), Hue/Saturation with `resolvedHSV`, or the filter panel of `filterKind` (declared in `LayerAdjustment.h`, as Swift's extension) with the four settings; then it keeps the original and opens one `Edit … Adjustment` transaction. The editors' layer has an id of its own, so the canvas never shows their previews, and none renders: `updateLevels`, `updateHueSaturation` and `updateFilter` hand the settings to `previewAdjustmentEditing` first (the edited settings, or the original with Preview off) and notify, since settings the layer refuses still change the editor; the three cancels and commits hand the edit to `finishAdjustmentEditing` first (a filter's after its Gradient Map picker closes, as in Swift). `finishAdjustmentEditing` writes the edited settings on OK and the original on Cancel, closes the three edits and the Hue tools (sample mode, targeting, a targeting drag), ends the transaction, clears the request, resumes file requests, asks for canvas focus and moves `brushRevision`. OK writes the settings out even unchanged, so an Exposure or Hue/Saturation layer made without them gains them in a step, as in Swift. A Gradient Map picker stays open when `finishAdjustmentEditing` is called directly (Swift's tests call it; the app's cancel and commit close the picker first), so `setGradientMapColor` keeps Swift's guards: with no filter open, or another kind, it writes nothing; its `committing` term is dead, since a commit closes the picker before it begins. Left out as dead: Swift's `editedAdjustment` returns nil when its kind's edit is gone, which never happens while `adjustmentOriginal` is set (the two are set and cleared together, and every path that closes an edit reaches `finishAdjustmentEditing` first), so the twin reads the edit through `value()`; the `?? .curves` behind `filterKind`, a checked read; `finishAdjustmentEditing`'s cancellation of previews (none runs for an adjustment's editor, and a histogram lands only on a Levels edit still open) and its `hueSaturationPending = nil` (only a preview sets it). Swift's `.task(id:)` cancels a render whose request changed; the landing's guard (the same request, no editor open) does that work here, and nothing in the app changes the request while a render runs (every writer sits behind `canEditLayers`). A fix beyond Swift: Swift's landing checks the request alone, so a Levels, Hue/Saturation or filter edit begun on other pixels while the render ran (choosing a layer and opening one stay possible, as in Swift) would be replaced or joined by the adjustment's editor; the twin's landing leaves that edit alone, clears the request, resumes file requests and logs a warning on `omaphoto.app`. A render or an editor that fails clears the request and sets `brushError`, as Swift's `catch`. `canEditLayers` and `canStartProjectOperation` read the request; `canUseHistory` does not, as in Swift, so undo stays open while the render runs, and an editor may open for a layer undone meanwhile, whose close writes nothing. Swift's `isGroup != true` there is left out as dead: a folder not beneath the adjustment holds nothing that is, and a live mask's source is never a folder. Equivalent mutants: the start guard's request term (the landing asks again; the start's only spares the render) and its `adjustmentOriginal` term (the original never outlives its kind's edit, which the next terms read). Tests: `AdjustmentEditorTests` (Swift's `sharedEditorsKeepPixelsDynamicAndSupportCancel` for every kind, then what lies beneath, the refusals, a failed render, an edit begun meanwhile, the gates and file requests, the Hue tools, a layer undone meanwhile, the signals), Swift's `newAdjustmentLayersStartFromThePaletteRenderAndEditInThePanel` in `ImageAdjustmentTests`, the request as a gate in `SessionGateTests`; tests that add an adjustment and edit on clear the request, as Swift's `add` does. - Editing adjustment layers in the window (9.5d): `LayersPanel` twins Swift's `.task(id: adjustmentEditingID)`: on every `changed()` (and when made, as a task runs on appear) it compares the request with the one it last began (`m_adjustmentTask`) and begins a new one; a render already under way for the same request lands nothing, so nothing needs cancelling. The footer's adjustment button is a `QToolButton` whose menu (Swift's `Menu`, `InstantPopup`) lists the six kinds by name and calls `addAdjustment`, resting while `canEditLayers` does not hold. Its glyph and the rows' are `LayerIcons` paths for Swift's SF Symbols; `symbol` names each kind's (circle.lefthalf.filled, slider.horizontal.3, the curve path turned a quarter clockwise as Swift turns it, plusminus.circle, paintpalette, circle.grid.3x3), and `adjustmentThumbnail` draws it 1.2 times the rail's size, centred in the row's 36-point square, as the text glyph is. An adjustment's row shows that glyph, "Adjustment · Double-click to edit", and no link for its mask (Swift's `linkable`); a double click on a control makes it active and asks for its editor (Swift's `renameClickedLayer`), one on the name renames. The Layer menu opens with Swift's New Adjustment Layer submenu (the kinds with an ellipsis, named `new…Adjustment`, resting while `canEditLayers` does not hold) and Edit Adjustment… (the active layer's request, for an adjustment layer alone), then a separator. The Levels sheet's caption says "Underlying pixels · alpha-weighted histogram" for an adjustment's editor, and the filter and Hue/Saturation sheets hide "Limited to the selection" there, as Swift's `adjustmentOriginal` terms. Tests: Swift's `adjustmentEditorsUseMovableNonmodalPanels` for every kind in `FloatingPanelTests`; `AdjustmentPanelTests` (the footer and its request, the rows and their glyphs, the Layer menu, the sheets' words); the Layer menu's count and names in `CompositorMenusTests`. - Layer effects in the document and the session (9.6a): `Document/LayerEffects.h/.cpp` twin Swift's model: the four effects with Swift's defaults, `LayerEffectKind` by raw value in Swift's order (`allLayerEffectKinds`), `LayerEffectSelection`, `LayerEffects` with its helpers. Swift's file runs to 544 lines, so its session extension is `LayerEffects+Session.cpp` with `LayerEffects+Session.h`, and its renderer arrives with 9.6b. `isValid` keeps Swift's bounds and leaves out the `isFinite` terms the bounds already refuse; `offset` falls away from the light, y down. The manifest's `effects` is Swift's synthesized `Codable`, written by hand in `IO/ProjectStore+Effects.cpp`: every key required but `enabled`, which stays out while absent; the store holds no rule for effects, as Swift's. Swift 1.1 gave the record an `effects` key but its `projectSnapshot` and `installProject` never carried it, so its saves and exports dropped the effects; Swift agrees with the twin since 1.2.0 (`5775dd1`, `6230e41`). The twin's record (`hierarchyRecord`) carries them and `ImageLayer(record, …)` loads them, so they save, load and, with 9.6b, export. Elsewhere they travel as Swift lists them: equality, a duplicate and the brush's two commits keep them; Invert's, Levels' and Hue/Saturation's commits, a floating merge and both resizers leave them out (the filter's keeps them since 12.9a); a copy between projects keeps them since 12.10b, as Swift 1.2.5's list does. The session's `effectsEditing`, `effectsEditingOriginal` and `effectSelection` are getters; 9.6d gave the first two setters, which notify on a change, for `ContentView`'s check, and made `dropEffectSelection` public for the canvas's press (the list's arrive with 9.6e). `selectLayer`, `selectLayers` and `selectLayerTarget` drop the chosen effect before any guard, as Swift's, so a refusal still drops it and says so (`dropEffectSelection`); Delete and the trash take a chosen effect first. `setEffects` finishes an opacity drag and makes one step of a valid change; `changeEffects` stays with the panel's layer, a step per change as Swift's; Cancel puts back the panel's effect alone. The colour picker gains Swift's effect target (`openEffectColorPicker`, `previewEffectColor`, `closeColorPicker`'s effect case, titles such as "Color Picker (Stroke Color)"); `ColorPalette.h` declares `LayerEffectKind` opaquely, since `LayerEffects.h` includes it. Left out as dead: the `isGroup` terms of `canEditEffects`, `setEffects` and `canCopyEffect` (a folder never holds pixels, `LayerHierarchy::validate` sees to it, so `asset` says the same), `selectEffect`'s `selectedLayerIDs = [id]` (`setActiveLayerID` sets it, and `selectLayer` cannot refuse behind `canEditLayers`) and its effect picker close (every path that clears `effectsEditing` closes that picker first, `ContentView`'s check since 9.6d included). Swift has no effects tests: `LayerEffectsTests` and `LayerEffectsSessionTests` pin its code, and `ProjectCopyTests` the copy. @@ -133,7 +133,7 @@ Platform adaptations, each forced by a missing Qt analog: - The Camera Raw panel (12.8b1, Swift 1.2.3): Swift docks the filter's `NSPanel` to the document window's right edge, full height, following it (`dockedToMainWindowRight`). Wayland lets no client place a window, and a tiling compositor would tile a second one, so the twin docks inside the window: `FloatingPanel::show` takes a dock slot, `ContentView`'s `panelDock` at the right of the middle row, `FloatingPanel::dockedWidth` (Swift's 440) wide, and there the panel is its own `PanelWindow` made a plain child (`Qt::Widget`), so `QDialog` still takes Escape as Cancel and Return as the default button; a header holds the title and a close button, which cancels. A docked frame is never remembered (`moveEvent` asks `isWindow`), so Gaussian Blur and the rest reopen where they were left, Swift's `dockedPlacementLeavesTheSavedFilterPosition`; `refocus` hands a docked panel the keys through `setFocus`, looking past the hidden floating panel of the same name, which is the window's child too. The dock takes room beside the layers panel and spans the middle row alone, so the tool bar and status bar keep their width, where Swift's panel lies over the window's whole height (known difference). Refocus hands the keys to the docked panel itself, not the field last typed in (known difference: `makeKey` keeps its first responder). `UI/CameraRawSlider` twins Swift's file: `CameraRawSliderTrack` with Swift's colours, the gradient drawn 4 points high across the groove under the style's knob, a double click on the knob (its rect two points out) resetting; a press on the track is `SliderSnap`'s (10.5): Swift's 0.18 s glide has no twin, and a drag goes on from the snapped knob, as every slider of the twin does (known differences); `display` keeps a knob under a drag, Swift's `isTrackingValue`; Camera Raw's own rows keep the track they are made with, since the rows whose tracks change (12.8b2's mixer) are made anew; since 12.14c `reshape` is Swift's track and bounds update in `updateNSView`, for the filter and Hue/Saturation sheets. `UI/CameraRawControls.h/.cpp` and `CameraRawControls+Basic.cpp` (Light, Color, Effects) twin Swift's view: the scope 110 high on black at 35%, the three ribbons at 55% in SwiftUI's dark red, green and blue (the Levels histogram's values) or the vectorscope's cells, square by the width and counted up from the bottom, so a 110-point graph shows its lowest 18 rows, as Swift's does, its context menu choosing the mode, the two clipping triangles lit in that blue and red, the readout; the words say Alt for Option and a right click for Control-click; the ten groups, Light and Color open, each eye shown while its group adjusts; a row's title (96 points at least, a double click resetting), `CameraRawSlider` and a 56-point `PickerField` showing Swift's `.number` to the row's decimals. A row's object name is its settings field (`vignetteAmountSlider`), since titles repeat. Alt is read from `QGuiApplication::keyboardModifiers`, Swift's `NSEvent.modifierFlags`, and its release (auto-repeat aside), from any window, ends the clipping and sharpen-mask views (Swift's local monitor). Curve to Calibration fill their groups in 12.8b2, and the Filter menu's entry waits for them. On the canvas (`EditorCanvas+CameraRaw.cpp`) a hover reads the readout (Swift's `mouseMoved`, never a drag), leaving clears it, the three eyedroppers sample and hand the panel the keys, the guide and the targeted drag begin, go on and end on release, and a move without the left button ends either, since Qt can lose a release; the four tools are `picking`, so the eyedropper shows. `EyedropperIcon::icon` came from `LevelsSheet`, which shares it. Left out as dead: the canvas's `kind == cameraRaw` and edit terms before the readout (the session asks), a hide of the other placement's panel in `show` (every edit closes its panel first), Swift's clipping reset before a row's reset (`assign` clears it), and the docked panel's own hide on close (its slot hides it). Equivalent mutants: the titles' minimum width, 96 points (a layout sizes labels offscreen alike), and the unlit triangle's 55% white (cosmetic, over whatever the scope shows). Two consequences of the named differences: a double click on a slider's track resets it, since the first press puts the knob under the second; and a canvas press takes the keys, so a field still being typed in commits at the click, where Swift's panel keeps its field editor. Tests: `CameraRawSliderTests` (Swift's three), `CameraRawPanelTests`, `CameraRawCanvasTests` (the dock in a real window among them), `FloatingPanelTests::dockedPlacementLeavesTheSavedFilterPosition`. - The Camera Raw panel's other groups (12.8b2, Swift 1.2.3): `UI/CameraRawColorControls.h/.cpp` (Curve), `+Mixer.cpp` and `+Grading.cpp`, `UI/CameraRawDetailOpticsControls` and `UI/CameraRawGeometryCalibrationControls` twin Swift's three files, each view a `QWidget` over the session, synchronized on `changed()`. `UI/CameraRawRow` is Linux-only: the row each Swift view builds for itself (`sharpenSlider`, `opticsSlider`, `geometrySlider`, `calibrationSlider`, the curve's and mixer's sliders, 12.8b1's `slider`): a title of at least or exactly a width, `CameraRawSlider`, a field or none, a double click on the title resetting where Swift's main groups do; each view rounds the slider's value as its Swift twin does (Detail to whole numbers, Optics when its range starts at zero, Geometry and Calibration always, the rest not). Swift's segmented pickers are `cameraRawSegments`, touching checkable buttons in an exclusive `QButtonGroup`. The curve graph: a still press and release adds a point, a drag past two points moves the nearest divider or inner point, a double click removes the nearest inner point within 0.04 and its trailing release adds nothing, so the net is Swift's: SwiftUI adds on each click, `repair` drops the second as a duplicate and the double tap removes one. The graph is `CameraRawCurveGraph`: a class defined in a `.cpp` outside an anonymous namespace is global, and the Curves sheet's `CurveGraph` would share its vtable across the link. The mixer's rows whose help or track follow its tab, swatch or chosen point are made anew when those change, as SwiftUI renders them anew; a picked colour's swatch is shown at once, since a layout shows late children only later. The grading wheel fills a `QConicalGradient`, which runs counterclockwise from the right as the hue does (1.2.9's `96edd5f`), takes presses and double clicks inside its circle alone (Swift's `contentShape`), and Swift's `theDockedPanelFitsTheGradingWheels` holds; its stops are 96edd5f's own, pure HSV hues every 30 degrees (v1.2.3's six SwiftUI system colours predate that fix). Detail's idle noise rows are disabled under a 45% `QGraphicsOpacityEffect`, Swift's `.opacity(0.45).disabled`. Targeted Adjustment and Draw Guides are checkable push buttons, checked while armed, where Swift tints them (known difference); their SF Symbols, `scope` and `line.diagonal`, are `LayerIcons` paths. The Filter menu lists Camera Raw Filter… after Lens Correction, as Swift's. Known differences: a mixer family's field still being typed in loses its typing when the tab changes (the row is made anew), where SwiftUI's field would commit into the new tab; Equivalent mutants: the wheel's saturation cap (`normalized` clamps it), the picked swatches made anew only when their count changes (it spares rebuilds; Qt guards a swatch deleted in its own click) and the page menu's size policy (layout). The point-colour eyedropper is flat like the other two, where Swift leaves it bordered (cosmetic). Cosmetic, Swift's values and unpinned: the swatches' 18 points, Detail's idle 45%, the hue ranges' inset (title width and 10), the rows' spacing of 10 and the 11-pixel subheadlines. Tests: `CameraRawRowTable.h` walked by `CameraRawGroupRowsTests` (every row against Swift's values), `CameraRawGroupDrawTests` (drawing, pages, gestures, the panel through `FilterSheet`), `CameraRawGroupWordsTests` (every text, captions, buddies, the pickers' states, the mixer's pages, the wheel's lower half and rim, the curve's line, repaint and selected point), `CameraRawCurveMixerTests`, `CameraRawGradingLensTests`, the menu's entries in `CompositorMenusTests`. - Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held. -- Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. Known difference, Swift's too: on the smooth path the canvas's noise follows the view, as Grain's does; on the crisp path the surface's region starts at the repaint region, so each partial repaint there draws a noise field of its own. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds a pixel wider. Tests: `BlurNoiseAdjustmentTests` (the model, manifest, store, seeds, the editor, the filters at a scale, the noise field, a scaled target, the canvas's zoom and a partial repaint's halo at 100%, 150% and 400%), `ImageTrimTests` (Swift's six, tolerance, the sheet, the controller through its sheet, a failure's alert), `InnerGlowTests` (Swift's, but the parity test, and the order), the new kinds in `LayerAdjustmentTests`, `AdjustmentEditorTests`, `AdjustmentLayerTests` and `AdjustmentPanelTests`, the glow in `LayerEffectsKernelTests`' replay, `LayerEffectsSurfaceTests`, `EffectsPreviewCacheTests`, `LayerEffectsTests` and `EffectsPanelTests`, version 9 across the store's tests, Trim in `CompositorMenusTests`. +- Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds a pixel wider. Tests: `BlurNoiseAdjustmentTests` (the model, manifest, store, seeds, the editor, the filters at a scale, the noise field, a scaled target, the canvas's zoom and a partial repaint's halo at 100%, 150% and 400%), `ImageTrimTests` (Swift's six, tolerance, the sheet, the controller through its sheet, a failure's alert), `InnerGlowTests` (Swift's, but the parity test, and the order), the new kinds in `LayerAdjustmentTests`, `AdjustmentEditorTests`, `AdjustmentLayerTests` and `AdjustmentPanelTests`, the glow in `LayerEffectsKernelTests`' replay, `LayerEffectsSurfaceTests`, `EffectsPreviewCacheTests`, `LayerEffectsTests` and `EffectsPanelTests`, version 9 across the store's tests, Trim in `CompositorMenusTests`. - The finishing filters (12.9a, Swift 1.2.4, `e08dd8c`): `kernels/AdjustPixels.c/.h` are v1.2.4's, with `adjust_colored_vignette` (Camera Raw's falloff pulled out as `vignette_mask`, Camera Raw's own vignette unchanged) and `adjust_tonal_contrast`. `FilterKind` gains Vignette, Bloom / Glow and Tonal Contrast after Add Noise, Swift's order, so the Filter menu lists them; `FilterSettings` keeps Swift's thirteen fields, defaults and clamps (the colour through `AdjustmentColor::clamped`). Vignette and Tonal Contrast hand the kernels every setting; Tonal Contrast's base is `PixelAdjust::gaussianBlur` at radius × scale, clear past the edge, Swift's unclamped `applyingGaussianBlur`. Known difference: Core Image's `CIBloom` is private, so the twin's Bloom / Glow is the pixels plus their Gaussian glow (radius × scale, clear past the edge) at amount ÷ 50, Swift's intensity, added byte by byte on the premultiplied values and rounded once, alpha included, so the glow shows past a clear layer's light as Swift's test asks; Core Image's own curve may differ. Bloom grows the grid by three radii and two and is trimmed on commit, as the blurs are. A commit with nothing to change closes as Cancel does: Vignette or Bloom at amount 0, Tonal Contrast at amount 0 or with all three tones at 0. Every filter's commit now keeps the layer's effects (Swift's rebuild gained `effects: current.effects`); shape and text still drop. `FilterEdit::previewSourceVersion` is Swift's: `prepare` moves it with each new grid, the worker hands back the version it rendered from, and a result for an older grid lands nothing, its waiting job rendering next (a render begun before `growForBlur` would otherwise show the old grid's pixels in the new placement). The twin's one watcher can hold a job waiting behind another edit's worker, which Swift's per-edit task never does, and Preview off keeps that job: a growth therefore rebuilds a waiting job on the new grid, so the version it starts with names its image. The picker gains Swift's Vignette target (`openVignetteColorPicker`, `previewVignetteColor`, "Color Picker (Vignette Color)"), closed with its colour by OK and without by Cancel, as the Gradient Map's; Swift's `committing` terms are left out as the Gradient Map's are (a commit keeps the project busy and closes the picker first), and so is `setVignetteColor`'s edit guard, read through `value()`: the Vignette picker lives only while its edit is open (Cancel and OK close it first), where the Gradient Map's may outlive an adjustment's editor. `UI/FilterSheet+Finishing.cpp` holds the three kinds' rows, FilterSheet.cpp running to 442 lines: Vignette's Color row (a 95-point title, the Gradient Map's 24-point swatch), Amount and Highlights with Swift's help, Midpoint, Roundness (no unit), Feather; Bloom's Amount and a logarithmic Radius; Tonal Contrast's Amount, Shadows, Midtones, Highlights and a logarithmic Radius. The sheet hands the picker's working colour to the Vignette and repaints the swatch on every change. Tests: `FinishingFilterTests` (Swift's five, then the kernels at a scale, the settings' bounds, the idle commits, Bloom's grid, a preview for an older grid, the picker, the sheet), the kinds in `FilterModelTests`, a filter keeping effects in `LayerEffectsTests`, the Filter menu in `CompositorMenusTests`. - Keyboard zoom, the rail and the tab strip (12.9b, Swift 1.2.4: `383548c`, `8053fb6`, `75c4219`). `CanvasViewport::keyboardZoomLevels` holds Swift's seventeen stops (12.5% to 1600%) and `keyboardZoomTarget` the next stop past the zoom within a billionth, or the zoom itself at an end; `EditorSession::zoomKeyboard` steps about the view's centre, leaves fit mode and stays silent when nothing moves; Swift's `step != 0` there is dead and left out (the viewport answers the zoom itself for a step of 0). The View menu's Zoom In and Zoom Out and the toolbar's zoom buttons step through the stops, the menu entries doing nothing while a text field or open text holds the keys (Swift's `NSText` guard; the entries' `m_field` and `m_canvas` with a draft). Swift's `handleKeyboardZoom` catches its zoom keys on key-down and repeat because a Mac menu key equivalent does not repeat; Qt's shortcuts repeat, and a matched shortcut never reaches a key filter. The menu's Ctrl+= and Ctrl+- also take Ctrl with the keypad's minus (probed in the app), so the canvas's window-wide filter (`handleKeyboardZoom`, beside the blend keys, outside text) takes only what Zoom In's entry cannot hold: Ctrl with Shift and = (Qt's Ctrl++) and the keypad's plus, never with Alt or Meta, and only while that entry keeps its default key (a remapped entry leaves the plus alone, as Swift's check of `ShortcutSettings`). Swift's zoom-out chords are all the menu's here, so the filter has no zoom-out branch. Swift's `8053fb6` pads the zoom buttons by 4 points; Qt's toolbar spaces its actions itself, left as it is (cosmetic). The rail already scrolls without scroll bars at 56 points (4.3), what `IndicatorlessScrollView` gives macOS. The tab strip follows `ProjectTabScroller`: each title button is Swift's `projectTabLabelWidth` wide (the title's width in its 12-pixel medium or semibold font, 10 more when unsaved, 35 to 155) plus Swift's trailing 8, the title elided within it and painted from the left (`TabTitle`, Swift's leading frame; a `QToolButton` centres its text), so a title moves only the tabs after it; the unsaved dot is a character, wider than Swift's 10 points of dot and gap, and widens its button by the excess (known difference). Since 13.14 the strip no longer scrolls: tabs that do not fit gather under the overflow pill and the front tab is always shown (Swift's drop slot has no twin, 4.7). Tests: `KeyboardZoomTests` (Swift's five, the tolerance from each side, and the key test in the app's own window: each chord's route, repeat, Shift-minus, Alt, Meta, text fields, a remapped entry, no document), `ProjectTabStripTests`, the menu in `ViewMenuTests`, the toolbar and strip in `ProjectWorkspaceViewTests`. - The format version in one place and the layer context menu (12.10a, Swift 1.2.5: `4cc9951`; `6fa115e`, `2f1e796`, `5c5f05b`, `1366f68`, `5d7adba`). `ProjectManifest::current` (9) is what saves write and `supported` (1 to it) what loads accept; the manifest's default, both version checks (`supports`) and the version error's range read them, so a bump is one edit. Swift moved the layer menu from each cell to the table; the twin moves it from `LayerCell::actions()` to the list: `NativeLayerList::contextMenu` is Swift's `contextMenu(for:)` (a row pressed with nothing selected is selected first; Duplicate Layer, Rename… for one layer alone, Delete Layer, Delete Selected Layers or Delete Mask, Create or Release Clipping Mask, Group Selected Layers, Move Out of Folder, the merge entry under `mergeTitle`, Add Mask with Reveal All (White) and Hide All (Black), Enable or Disable Mask, Delete Mask, Link or Unlink Mask, Show or Hide Layer, with Swift's separators and each entry enabled as Swift's `validateMenuItem`, the mask entries targeting the pixels first; 1.2.5 left Layer Effects out; a selection refused (Levels open) leaves no active layer, and every entry then rests); `menuFor` is Swift's `menu(for:)`, routing the press first: on an effect's row that effect is chosen (Swift's `selectEffect(at:)`), elsewhere the chosen effect goes, a row counts as selected only while no effect is chosen (`selectedEffect`, which a stale choice an undo left fails, as the row's painting reads it; Swift's table then shows none), the pressed row's id is read first (Swift's `targetLayer`), since a selection may finish text and rebuild the rows, a selected row keeps the selection with the pressed layer primary unless a thumbnail targets its mask or pixels, and an unselected row becomes the selection, the list takes the keys and its mask or pixels are targeted. `LayerCell::contextMenuEvent` executes that menu; below the rows none shows. Left out as dead: Delete Mask's `mask != nil` (the session chooses a mask only while it exists) and the routing's `effectSelection = nil` (`selectLayers` and `selectLayerTarget`, which every path calls, drop a chosen effect themselves). The entries act on the layer active when chosen, as Swift's `@objc` actions read it, where the old cell menu acted on its own row; Rename asks `canEditLayers` again then, as Swift's `renameSelectedLayer`. Tests: `LayerContextMenuTests` (Swift's regression tests, the folder duplicate included; titles; a real right-click), `LayerContextMenuEdgeTests` (the gates at their edges, a refused selection, action-time targets and an entry with no active layer, a chosen or stale effect, a rebuild under the press), `LayerContextMenuFixtures.h`, `theCurrentFormatVersionIsOneTheReaderAccepts` in `ProjectTests`, the old cell-menu cases in `NativeLayerListTests` and `LayersPanelTests` moved to the list's menu. @@ -167,6 +167,9 @@ Platform adaptations, each forced by a missing Qt analog: - New Canvas presets and the Quick Look preview (13.7, Swift 1.3.6–1.3.7: `aaf3dc0`, `14aad18`, `4868465`): `NewCanvasSheet` twins `aaf3dc0`: the subtitle goes; beside "New canvas" a 28-point `QToolButton` (`presetSizes`, an instant popup) draws Swift's three 2.5-point dots in `WindowText`, flush with the fields' right edge, its own `paintEvent` drawing no menu arrow; Swift's tooltip and the accessible name "Preset sizes". `CanvasPreset` (in `NewCanvasSheet.h`, as Swift's file holds it) keeps Swift's `groups` and `all`; the menu is Custom, then each group after a separator, checkable `QAction`s in an exclusive `QActionGroup`, each holding its index in `all` (Custom −1). `checkPreset` checks the first preset whose sizes print as the fields' exact text, else Custom, on every edit of either field, as Swift's binding reads it; choosing a preset fills both fields; Custom fills nothing and checks the match again, since Qt checks a triggered action where Swift's picker sets nil and re-reads. Known difference: Swift's label is clickable ten points past the dots into the padding; the twin's button ends at the content's edge. The Quick Look preview twins `14aad18` and `4868465`: `ImageExporter::quickLookImages` returns nothing past 50 megapixels (logged as info), else renders the snapshot and `scaledJPEG`s it: the long side at most 1024, sides rounded, a pixel at least, `QImage::scaled` with `SmoothTransformation` (area averaging when shrinking) for CoreGraphics' high quality, drawn on white into `RGB888`, JPEG quality 80, 72 dpi as `CGImageDestination` writes; a render or scale that fails logs a warning on `compositor.io` and saves without one, Swift's `try?`. `QuickLookImages` (Swift's, in `ImageExporter.swift`) is declared in `ProjectStore.h`, since the exporter includes the store. `ProjectStore::save` takes it optionally: with one, the staged folder gains `QuickLook/Preview.jpg`, written and synced with its folder before the project's folder is synced and swapped in; without one the swapped-in project has none. Loading reads only the manifest and `images/`, so the folder is ignored, and `ProjectDigest` reads neither, so writing or deleting it is no external change. The controller's save worker makes the preview and hands it to the store. `docs/writing-comp-files.md` gains Swift's line, OmaPhoto's name. Equivalent mutant: the heading's stretch (the title label takes the room). Untested: the null checks after scaling and allocating the flattened image (an allocation failure); without them a null scale would save a white preview. Tests: `NewCanvasSheetTests` (the dots filled and never outlined, `presetsAreSwiftsGroups`, `aPresetFillsTheFieldsAndTheMatchIsChecked`, `theMoreButtonDrawsThreeDotsAndOpensTheMenu`), `QuickLookPreviewTests` (the store's folder and its absence, load ignoring it, the JPEG byte for byte against smooth scaling on white at quality 80, sizes and rounding, the 50-megapixel bound, a failed render, a controller save), and the sync order in `ProjectReplaceTests`. - Tabs reorder and overflow (13.14, Swift `1faf7a0`): `UI/ProjectTabLayout.h/.cpp` twin Swift's file: `projectTabSpacing`, `ProjectTabSlot`, `ProjectTabPillSlot`, `ProjectTabOverflow` (`contentWidth()` for Swift's computed property), `projectTabOverflowLabel` and `projectTabOverflow`, widths a `std::map` read through `at` (every tab has one), the pill's width a `std::function`. Left out as dead: `span`'s empty guard (never asked of no tabs) and `contentWidth`'s pill fallback (a pill always has a tab after it). `ProjectWorkspace::moveTab` is Swift's, clamped, and emits `changed()` only when a tab moved. `ProjectTabStrip` is now a plain `QWidget` that takes the toolbar's free width (the spacer after it went; its size hint is every tab in a row, its minimum none) and places its children by hand from `projectTabOverflow` over its own width: the overflow pill at x 0 and the visible tabs at y 3, 28 high, each as wide as its button's size hint (so the unsaved dot's extra width counts, as before), hidden tabs hidden; it lays out again on `resizeEvent`, on the workspace's signal and on each tab's session signal (a title or a dot changes widths; a hidden button posts no layout request). The QScrollArea, its offset and `showFront` are gone, as Swift's scroller and reveal are; the selected tab is always shown by the pinning. `OverflowTabsPill` (a `QToolButton`, `projectTabsOverflow`) paints an inactive tab's capsule, the label "N more tabs" at 12 pixels medium and a drawn `chevron.down`, the label and chevron at half opacity while the workspace cannot switch (the capsule stays, as Swift's `.opacity` sits before its background); tooltip and accessible name are the label; its width is Swift's `projectTabOverflowPillWidth` (the label's ceiling plus 11, 4, 10, 11). Its press opens a `QMenu` (`projectTabsOverflowMenu`, deleted on close) four points below it, as `popUp(positioning:)` does, listing the hidden tabs in order, "• " before an unsaved one, a choice selecting; while the workspace cannot switch no menu opens. The reorder twins Swift's: `ProjectTabButton` watches its title button's left press, move and release in global coordinates (Swift's `.global` space) and reports `TabDragPhase::changed` once the press has moved three points and `ended` on the release; the strip's `Reorder` is `TabReorderState` (others, widths, compacted x, start, origin, translation, target), `nearestSlot`, `renderX`, `makeReorder` and `commitReorder` as Swift's (a neighbour closed mid-drag has no place, as Swift's `if let`s). A drag begins only while the workspace can switch and across at least three points, selects its tab first and raises it over the others (Swift's `zIndex`); it commits on release only if the workspace can still switch; the others ease to their places through a 150 ms `QPropertyAnimation` of `pos` (`OutQuad` for SwiftUI's `easeOut`), the dragged tab following the pointer at once, and every tab eases home when it ends. Qt can lose a release: a drag whose tab closes is dropped on the workspace's signal (Swift's timer drops it too), and a drag on another tab drops a stale one first (Swift's timer would have cleared it within 0.1 s). Known difference: a lost release on the same tab leaves its stale state for that tab's next drag, which then measures from the old origin. Known difference: Swift's empty title bar beside the tabs drags the window (`TitleBarDragView`); Qt's toolbar is no title bar, so the strip's empty part does nothing. `NewTabDropSlot` has no twin, as before. Equivalent mutant: reporting `ended` after a click that never dragged (Swift's gesture never ends one); it differs only after a lost release, where it would drop another tab's stale drag a click sooner. Tests: `ProjectTabLayoutTests` (Swift's six, the bounds at exactly fitting, a lone wide pinned tab, the pill's width asked by count), `ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments` (Swift's, plus its signal), `ProjectTabDragTests` (following the pointer, clamping, the gap, the slide's duration, curve and end, stacking, landing left and right and in place, a small or upward move as a click, a busy project at the press and at the drop, a tab closed mid-drag, a lost release giving way to the next drag, a three-point move and a move under another button, the overflow pill's label, place and width, the menu's entries, place and choice, pinning, the busy pill), and `ProjectWorkspaceViewTests` (overflow in the window; the strip ends before the zoom buttons). `ProjectTabStripTests::onlyANewFrontTabScrollsIntoView` went with the scrolling. - Ungroup Layers and the mask pointer (13.15, Swift `1318f1e`, `6c3b9a5`, `6955a6f`): `EditorSession::canUngroupLayers` and `ungroupLayers` sit in `LayerGroups.cpp` (declared in `LayerGroups+Session.h`), as Swift's extension: the folder's direct children, their parent now the folder's own, take its place in the list in their order; `releaseDetachedClipping` then frees clips that no longer sit on their base; `finishOpacityEdit` ends a drag first; one `Ungroup Layers` step selects the children with the lowest active (none for an empty folder) and forgets the folder's collapse. Swift's `LayerHierarchy.validate` guard is dead and left out: unwrapping a folder only shortens parent chains, so nothing it refuses can appear; its `document` guard is `canEditLayers`'. The Layer menu gains Ungroup Layers (Ctrl+Shift+G) after Group Selected Layers, enabled by `canUngroupLayers`, and `ShortcutDefinition::all` Swift's "Ungroup Layers" entry (g, Cmd+Shift), so it can be remapped; the list's context menu shows it after Group Selected Layers for a right-clicked folder alone (Swift reads the clicked row, not the active layer), enabled by `canUngroupLayers`. `NativeLayerList::showMaskCursor` twins Swift's `showMaskCursor`: the duplicate pointer drawn over an eye in a 7.5 by 5.5 box at the hot spot plus (15, 18), haloed by sixteen white copies one point out, the image widened to hold it (29 by 32 at 1×), the hot spot the pointer's; Swift's `eye.fill` is drawn as paths (an almond of two quadratic curves, a ring cut round a filled pupil). Over a mask thumbnail with Alt, `cursorFor` shows it whatever `canEditLayers` says, as Swift's now does (13.12 brings the Alt-click that shows the mask alone). Equivalent mutant: the context entry enabled by `canEditLayers` (the right-click makes the folder active, so `canUngroupLayers` says the same). Tests: `GroupSessionTests` (Swift's three ungroup tests; collapse forgotten, signals, a plain layer refused silently, a nested and an empty folder, an opacity drag ended, busy), `LayerContextMenuTests::aFoldersMenuOffersUngroup`, the menu entry and its gate in `CompositorMenusTests`, the shortcut count in `KeyboardShortcutsTests`, the eye pointer's pixels and hot spot in `LayerDragGestureTests`. +- Compositor 1.4, part one (13.8: `8c0417b`, `fe7a83d`, `af3b568`, `3ccc125`, `d0135c8`, `dcf1c66`). A sharper canvas: `LayerRenderer::interpolation` takes Swift's `upright` (a layer whose `radians()` is 0, drawn within a thousandth of pixel for pixel, takes no filter), from `LayerRenderer::draw` and `TiledLayerRenderer`'s frames alike. The twin's adjustment surfaces already held device pixels (9.5b) and its adjustments already ran at the surface's scale (12.8c), so 1.4's surface and `adjustmentScale` changes were held; what was missing is ported: `AdjustmentSurface::draw` falls back to a pixel a painter unit when device pixels pass the budget (Swift's "falls back to points"), drawn back smoothly (Swift's `.high`), and nothing only when that passes too; `LayerAdjustment::apply` counts the noise origin per axis (`top × height / max(1, region height)`), where the twin used the width's scale for both. PSD (`IO/PSD`): Levels gamma in hundredths; `hue2` read as Swift's: 16 bytes at least, Colorize's own values or the Master's, then for each of the six ranges its band (degrees wrapped into 0–360) and values, 14 bytes each, until the data runs out; the reader keeps each mask's bounds and default byte (`PSDRecord::maskBounds`, `maskDefault`), and `PSDDocumentBuilder::maskOnLayerGrid` draws the stored patch, unfiltered, onto the layer's grid (its pixels, or the canvas for a layer without) at its place, the default round it; already the grid, or with no size, the patch is kept; a grid that cannot be allocated logs on `omaphoto.io` and takes the builder's existing "couldn't be converted" note, as Swift's nil does. `PSDAdjustments::levels` and `hue` are public, as Swift made them for its tests. Photoshop's Saturation: `HueSaturationFilter::adjustedSaturation`, Swift's to the letter. All cores: `kernels/LevelsPixels.c/.h` are Swift's file again (`cube_apply`); `BrushRaster::copy` (bytewise for the context's own format, drawn otherwise) and `BrushRaster::inBands` (one band under 250,000 pixels, else twice the ideal thread count, through `PoolMap`; a `bad_alloc` there is `ExportError(render)`, since the workers' callers catch that alone); `LevelsFilter::run` copies and runs `levels_apply` in bands; `HueSaturationFilter::run` copies and runs `cube_apply` in bands, where the twin had its own trilinear loop (the kernel's float order now: within a level of it, Swift's tests allow eight); `cube` keeps the last eight tables under a mutex, Swift's `cubes`. A blur's preview kept while the layer grows: `FilterEdit` gains `preparedTransform` and `pendingTransform`; a render carries the grown layer it was made from (`Filtered::placement`) and lands there, shown until the next replaces it; `displayedTransform` and `displayedMaskPlacement` read `preparedTransform`. Left out as dead in the twin: Swift's `current` terms (only a blur grows its grid, and only the automatic kinds, which never grow, refuse a stale preview or read `preparedSettings`), so the grid's version (`previewSourceVersion`, `Filtered::sourceVersion`) goes with nothing left to read it, and so does the twin's own remaking of a waiting job on growth (with Preview off nothing lands; with it on, the update makes a new job). The hierarchy kept: `LayerOrder` (`Document/LayerGroups.h`, its code in `LayerGroups+Order.cpp` with the document's hierarchy members, since `LayerGroups.cpp` would pass 500 lines): `Node`, `Result` (`order`, `visible`, `drawn`) and `resolve`, which keeps the last nodes and answer under a mutex; `CanvasDocument::hierarchy()` replaces `hierarchyEntries()`; `effectiveVisibleIDs`, `renderLayers` (a checked `std::map` index, after Swift's empty-order guard) and `effectiveOpacities` (a map of opacity and folder, no whole layers) read it; `groupSelectedLayers` reads `hierarchy().order`. `dcf1c66` was held: the canvas's `DisplayState` already works out the visible set once. Tests: `SharpCanvasTests` (the rule's edges; plain, masked and tiled layers crisp within the tolerance, filtered past it), `PSDAdjustmentTests` (Swift's three, plus the wrap, Colorize leaving the ranges, a cut record, the builder, a pixel layer's grid, a stretched layer, the patch kept, bounds or a layer of no height or width, the grid's bounds with a smaller patch, a patch the grid's size a pixel along), `PSDReaderTests` (the new layouts, the mask's bounds and default read and placed), `HueSaturationTests::positiveSaturationMatchesPhotoshop` (Swift's values, the clamps, a pixel), `AdjustmentBandsTests` (bands cover each pixel once, the copy's bytes, Levels and Hue/Saturation in bands against one pass of the kernels, cached tables), `HueSaturationFailureTests` (the filter now fails at its copy, the row list being gone), `FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne` (Swift's) and `aMaskKeepsItsPlaceUnderAPreview` (a covering mask under a preview that did not grow, and on a grown one the old bounds), `FilterSessionTests` and `FinishingFilterTests` (an older grid's preview shows on that grid: `aPreviewForAnOlderGridShowsOnThatGrid`, `aJobWaitingBehindAnotherEditShowsOnItsGrid`), `LayerOrderTests` (order, visibility and drawing against `LayerHierarchy::entries`, each shaping change seen, 64 deep, nothing shown draws nothing, opacities up known folders alone and a repeated id refused), `GroupTests`, `BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions` (each axis by its own scale, a region under a unit), `AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn` (the fallback, enlarged smoothly; a quarter of a pixel makes one). Equivalent mutants: `adjustedSaturation`'s clamps of the amount to ±1, its `max(0, …)` below zero and `share > 0` for `>= 0` (each branch gives the same number there; Swift's to the letter); the cube cache's insert, its removal of an older entry for the same settings and its trim to eight (each only spares work or memory); `BrushRaster::copy`'s bytewise branch taken never (drawing copies the same bytes), `inBands`' threshold moved by one and its `start < count` guard (Swift's; no start reaches the count unless the thread count passes some 500); `LevelsFilter::run` drawing its copy (the same bytes); `LayerOrder::resolve` keeping nothing (the cache only spares work). Untested failure path: `maskOnLayerGrid` out of memory (only an allocation that fails reaches it). Judgments: the adjustment surfaces' device resolution and `adjustmentScale` were held (9.5b, 12.8c); `dcf1c66` was held (the canvas already works out the visible set once); the rest ported. Mutants: 60 run, every one caught or recorded above (two unbuildable forms rewritten and caught). +- Blending (13.9a: `6dc2219`, `8ab32c9`, `5a8f6ce`, `e6c964b`). Held, proven: clipping stacks and live-mask coverage at device resolution (the twin's `LiveMaskRenderer` has no `bounds`: its group, alpha and coverage surfaces are the painter's device since 4.x, so Swift's `resolution` and `pixelSize` have no twin); an adjustment in any mode, over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`adjust`'s `blended` goes through `LayerRenderer::composite`, which hand-blends every mode QPainter lacks through `HslBlend`, so Swift's `SeparableBlend.blend` has no twin); a clipping stack composited in its base's mode in every mode (`drawComposite` hands `m_stackModes` to `LayerRenderer::composite`, the same path), on the canvas, in export and in Copy Merged alike. Ported: Soft Light by Photoshop's formula, as Core Image computes it (2bs + b²(1 − 2s) up to half, 2b(1 − s) + √b(2s − 1) above), in `SeparableBlend::channel`, and `needsSurface` names Soft Light, so `HslBlend` blends it by hand everywhere a layer, a stack or an adjustment is drawn; `LayerRenderer::compositionMode` loses its Soft Light case (dead now). Compared first: Qt's `CompositionMode_SoftLight` is the PDF's (W3C) formula, which equals Photoshop's below a source of half and above it wherever the backdrop passes 0.25; under that it takes the PDF's cubic for √b and runs up to 17 levels darker (backdrop 5/255, source 1: 19 where Photoshop gives 36). Swift's 0.9 over 0.5 gives 170 under either formula. Known difference: Swift's Core Image is "within 5 levels of Photoshop's"; the twin is Photoshop's own formula, so it may differ from Swift's by those few levels. Tests: `BlendingTests` (Swift's `clippingStacksBlendInTheirBasesMode` for Linear Dodge, Color Dodge and Multiply: Swift's numbers, Copy Merged equal to export, the canvas within a level; adjustments in Difference and Linear Light over the document and Vivid Light and Subtract inside a stack, by the numbers, at full and half opacity, in all three places; Swift's `softLightMatchesPhotoshop` plus a dark backdrop and the lower branch; Swift's `softEdgedLayersBlendTheSame` for Soft Light, Overlay, Multiply and Color Dodge: a radial soft layer over noise, Copy Merged equal to export, the canvas within a level), `BlendModeTests::everyBlendModeMatchesThePdfFormulas` (its Soft Light reference is Photoshop's now; its backdrops of 0.1 and 0.25 under sources past half catch the PDF's cubic), `ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels` (a half-unit stripe stays sharp at a device scale of two, in a stack and through coverage from a source further down). Equivalent mutant: Soft Light's `source <= 0.5` for `< 0.5` (both branches give the backdrop at a half). Judgments: stacks and coverage at device resolution, adjustments in any mode and a stack in its base's mode were held (proven by the tests above); Soft Light ported. Mutants: 5 on Soft Light, 4 caught, one equivalent (above). +- The Metal canvas (13.9b: `cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`). OmaPhoto stays a CPU canvas (the port's decision): Swift's GPU canvas, its `GPUCanvas`, `GPUNoise`, `MetalWarp` and `CanvasLinesOverlay` and the GPU branches of `EditorCanvas`, have no twin; the twin's canvas is the Core Graphics canvas those commits compare against. Ported: Grain and Add Noise anchored to the document (`54dd707`): `LiveMaskRenderer::adjustmentRegion` (Swift's `adjustmentRegion`) maps what the painter shows into the document, and the canvas's `drawLayers` sets it from its centre mapping and zoom, so the smooth path counts noise in frame pixels from the document's corner and Grain in document pixels, as export does, and the crisp path's surface maps to document pixels, so a partial repaint keeps one field; the blur's scale still comes from the unmapped region. This retires 9.5b's and 12.8c's known differences (Grain and noise following the view, a partial repaint's own field); `CanvasDisplayTests::grainFollowsTheViewOnTheCanvas` is gone. Export and `drawLiveComposite` already draw in document pixels and set no region, as Swift's. Tests: `AdjustmentCanvasTests` (Swift's `matchesEveryAdjustment`: every kind through a ramp mask at 80% over a photo and a translucent layer, the canvas within a level of the export over its checkerboard; Swift's `patternsStayWithTheDocument` for Add Noise and Grain: equal to export at 100% and 200%, panned and not, and at 80% a whole-pixel pan moving the pattern with the image and a partial repaint showing the whole's pattern within two levels of resampling), `CanvasEditContractTests` (Swift's `aNewMaskRevealsTheWholeLayer`, upright and turned: a one-pixel mask on a scaled layer changes no covered pixel, the canvas as the export; `matchesWhileMovingPixels`, moving and duplicating on a masked layer at 60% under a soft selection: the preview equals the commit and the export outside the ants, the hole only for a move; `matchesWhileDraggingAGradient` in a selection at 70%: the pending gradient equals its commit outside the overlays, nothing outside the selection; `5708ed3`'s failed fill cancelling with `brushError`, no step, the document unchanged; `matchesALiveMaskOutsideAClippingStack`: a turned source with a layer between, the canvas as the export), `CanvasContractFixtures.h` (the scene, the export over the canvas's checkerboard, a gap that skips the overlays' bands, the ramp mask). `cdf8674`: rotation, flips, scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom, sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on pixel layers, filter, Levels and Hue/Saturation previews in the layer's place: held; the CPU paths are the reference and are pinned by `LayerRendererTests` (placement, sampling rules, halvings, overhang), `DownsampleTests`, `BlendModeTests`, `CanvasDisplayTests` (checkerboard, shadow and border, crisp from 200%, the rich scene against the export), `FolderOpacityTests`, `FolderMaskClipTests`, `MaskPlacementTests`, `LevelsCanvasTests`, `HueSaturationCanvasTests`, `VignetteCanvasTests`. `4b04323`: a moved or duplicated selection's preview and commit: held and now proven on a masked, translucent layer (above). Swift's laziness (`PixelMove.applyOffset`, `holed`, `liftedTarget`) only spares the GPU canvas a rebuild per move; the twin rebuilds eagerly in `movePixels`, which realises the tiles before display and commit by construction, and a failure there cancels the move with `brushError` (`PixelMoveTests::aPixelMoveHoldsTheSessionAndAFailedMoveCancels`). `5708ed3`, `91cb0b1`: live paint, erase and mask paint, paint past the old bounds under the old mask (`TiledStrokeTests`, `BrushPreviewTests`, `MaskPaintAnywhereTests`), Smudge and Liquify previews through the mask, opacity and placement (`SmearCanvasTests`, `FolderOpacityTests`): held. `GradientEdit.Fill` and `applyFill` are the GPU's laziness; the twin fills in `refreshGradient`, before display and commit by construction, a failure cancelling with `brushError` (now tested), and the commit publishes what was filled. `MetalWarpTests.matchesTheCPU`: the twin's `WarpStroke` is the CPU reference (`SmudgeLiquifyTests`); nothing to port. `93de9e7`: a one-pixel white mask revealing its layer: held (QPainter's smooth draw clamps an image's edge, as Core Graphics draws it; now tested); the live paint grid's extent and edge sampling: held (`TiledStrokeTests`, `DownsampleTests`, `TiledLayerTests`). `clampedToExtent` is GPU-only. `54dd707`: every adjustment kind with alpha, masks and opacity, blurs at the drawn scale and direction, adjustment results equal to the CPU reference, deterministic seeds: held (the canvas runs the CPU filters themselves; now tested for every kind); Grain and Add Noise anchoring: ported (above). `f773f7b`: non-adjacent live-mask sources (now tested), distortion previews (`DistortCanvasTests`, `DistortMaskTests`, `EffectsCanvasTests`), text previews keeping and handing on effects (`TypedTextCanvasTests`), drafts above the active layer (`ShapeCanvasTests`, `TypedTextCanvasTests`; inside a clipping stack the draft is drawn by `drawOwnWithDraft` inside the stack's group, as Swift's), new text when the active layer cannot draw, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`EffectsCanvasTests`, `MaskEffectsCanvasTests`, `BrushCanvasTests`, `MaskPaintAnywhereTests`): held. `distortShape`, the now-internal `imageCorners` and `editedText`/`handOffTextEffects` are GPU plumbing or a refactor of what the twin already draws. Known difference: Swift moves the pixel grid and a new text frame into an overlay view below its transform overlay, so the grid draws over the document's border hairline and the text frame under the other overlays; the twin draws the grid inside the document under the hairline and the text frame last. Both sit above the pixels and take no input; the order shows only on the hairline's edge pixels (a 13% white line), and no other overlay shows while a text frame is dragged. A port was tried and left: QPainter's antialiased hairlines make the order unprovable to a level. `4c5e1a6`, `d04f159`: texture retention, eviction and blits are GPU mechanisms; the CPU's sharp reductions are `DownsampleCache`'s, pinned by `DownsampleTests`. `b1b7490` only words a test's diagnostics. Tests tried and left: a distortion's preview against its commit differs on noise (the commit resamples the warp at its trimmed place), which Swift's tests never claim (they compare two previews); `DistortCanvasTests` pins the preview. Mutants: the region's mapping and use in `LiveMaskRenderer::adjust` and each term of the canvas's `adjustmentRegion`: 6, all caught (the width and height terms only once the partial repaint was compared). Judgments (each Swift contract, held unless named above): - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/TASKS.md b/TASKS.md index 108801e..7ee6db5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -433,9 +433,12 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - 13.6 review (Fable 5.1): round 1 FAIL (four survivors unrecorded; the code judged right against Swift); round 2 PASS. - [x] 13.7 Versions 1.3.6–1.3.7: New Canvas presets in a More menu (`aaf3dc0`); the project's Quick Look preview (`14aad18`, `4868465`) judged: the package's preview file is format, Finder's thumbnail extension is macOS alone. - 13.7 port (agent): tests in `NewCanvasSheetTests` (presets, the check, the dots), `QuickLookPreviewTests` (the store's folder, load ignoring it, the JPEG byte for byte, sizes, the 50-megapixel bound, a failed render, a controller save) and the sync order in `ProjectReplaceTests`. Survivors recorded: the heading's stretch (equivalent); the scale and allocation null checks untested. No review yet. -- [ ] 13.8 Version 1.4, part one: the changes behind the GPU canvas that the CPU canvas and export share: a sharper canvas with adjustment layers (adjustment surfaces at device resolution, adjustment operations scaled with the surface, direct sampling for an upright layer at 1:1, tiled layers included) and the PSD adjustment and mask fixes (`8c0417b`); Photoshop's positive Saturation (`fe7a83d`); Levels and Hue/Saturation on all cores (`af3b568`); a blur's preview kept while the layer grows (`3ccc125`); the hierarchy kept until what shapes it changes (`d0135c8`, `dcf1c66`). -- [ ] 13.9a Part two: blending. Clipping stacks and live-mask coverage at device resolution; an adjustment in any mode over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`6dc2219`); a clipping stack composited in its base's mode in every mode, on the canvas, in export and in Copy Merged (`8ab32c9`; Qt keeps the stack's mode: a held judgment to prove); Soft Light by Photoshop's formula as Core Image computes it, on the canvas, in export and in Copy Merged (`5a8f6ce`: a 0.9 source over a 0.5 backdrop gives about 170; compare Qt's formula first); soft, partly clear painted layers blending alike in Soft Light, Overlay, Multiply and Color Dodge (`e6c964b`, `softEdgedLayersBlendTheSame`). -- [ ] 13.9b Part three: the Metal canvas (`cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`) stays CPU (the port's decision); each contract its tests hold for every canvas is judged and, where the twin lacks it, ported: Grain and Add Noise anchored to the document while panning and zooming, the canvas mapping its surface back to document coordinates (`54dd707`; the twin's adjustments take view-space regions today); every adjustment kind with alpha, own and folder masks and opacity, blurs at the drawn scale and direction (`54dd707`); a moved or duplicated selection's preview and commit (the hole cut only for a move, the rounded offset, opacity and coverage once) and the lazy tiles realised before display and commit, a failure cancelling the move with `brushError` (`4b04323`); live paint, erase and mask paint replacing patch interiors, revealing paint past the old bounds under the old mask, gradients' selection clip and opacity matching the commit, the lazy gradient fill realised before display and commit, Smudge and Liquify previews keeping mask, opacity and placement (`5708ed3`, `91cb0b1`); a new one-pixel white mask revealing its whole layer, rotated or scaled without a faded edge, and the live paint grid's extent kept (`93de9e7`); rotation, flips and scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom (`cdf8674`); non-adjacent live mask sources, distortion previews (perspective, folded, flipped, placed masks, effects), text previews keeping effects and handing them on without a flash, new drafts above the active layer inside a clipping stack, lines above the image, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`f773f7b`); sharp reductions and fresh paint pixels (`4c5e1a6`, `d04f159`); `b1b7490` only words a test's diagnostics. Also named, each to judge: sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on ordinary pixel layers, and filter, Levels and Hue/Saturation previews replacing the layer's pixels with its alpha, mask, opacity and placement (`cdf8674`); image-edge sampling that carries edge values outward for filtering and keeps the image's own boundary, for ordinary images and live grids as for the white mask (`93de9e7`); adjustment results equal to the CPU reference, deterministic Noise and Grain seeds, Add Noise counted in frame pixels from the document's corner and Grain's size in document pixels (`54dd707`); a lazy gradient fill that fails cancelling the gradient with `brushError`, and a failed commit publishing it (`5708ed3`); Smudge and Liquify strokes and commits equal to the CPU algorithms (`91cb0b1`, `matchesTheCPU`: the twin's CPU path is the reference, a held judgment); new text shown when the active layer cannot draw, the pixel grid and the new text frame drawn above the image and passing input through, and edits keeping mask gray values and effects and placed masks as paint bounds grow (`f773f7b`). Texture retention, eviction and blits are GPU mechanisms with no CPU twin. +- [x] 13.8 Version 1.4, part one: the changes behind the GPU canvas that the CPU canvas and export share: a sharper canvas with adjustment layers (adjustment surfaces at device resolution, adjustment operations scaled with the surface, direct sampling for an upright layer at 1:1, tiled layers included) and the PSD adjustment and mask fixes (`8c0417b`); Photoshop's positive Saturation (`fe7a83d`); Levels and Hue/Saturation on all cores (`af3b568`); a blur's preview kept while the layer grows (`3ccc125`); the hierarchy kept until what shapes it changes (`d0135c8`, `dcf1c66`). +- 13.8 port (agent): tests in `SharpCanvasTests`, `PSDAdjustmentTests`, `AdjustmentBandsTests`, `LayerOrderTests`, `PSDReaderTests`, `HueSaturationTests`, `FilterTests`, `FilterSessionTests`, `FinishingFilterTests`, `BlurNoiseAdjustmentTests`, `AdjustmentSurfaceTests`, `HueSaturationFailureTests` (it now fails at the copy). Survivors recorded: Saturation's clamps, the cube cache, the copy's bytewise branch, the bands' guard (equivalent); the mask grid out of memory untested. No review yet. +- [x] 13.9a Part two: blending. Clipping stacks and live-mask coverage at device resolution; an adjustment in any mode over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`6dc2219`); a clipping stack composited in its base's mode in every mode, on the canvas, in export and in Copy Merged (`8ab32c9`; Qt keeps the stack's mode: a held judgment to prove); Soft Light by Photoshop's formula as Core Image computes it, on the canvas, in export and in Copy Merged (`5a8f6ce`: a 0.9 source over a 0.5 backdrop gives about 170; compare Qt's formula first); soft, partly clear painted layers blending alike in Soft Light, Overlay, Multiply and Color Dodge (`e6c964b`, `softEdgedLayersBlendTheSame`). +- 13.9a port (agent): Soft Light by Photoshop's formula (Qt's is the PDF's, up to 17 levels darker on dark backdrops); stacks, coverage and adjustments in every mode held and proven. Tests in `BlendingTests`, `BlendModeTests`, `ClippingStackTests`. Survivor recorded: `<= 0.5` for `< 0.5` (equivalent). No review yet. +- [x] 13.9b Part three: the Metal canvas (`cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`) stays CPU (the port's decision); each contract its tests hold for every canvas is judged and, where the twin lacks it, ported: Grain and Add Noise anchored to the document while panning and zooming, the canvas mapping its surface back to document coordinates (`54dd707`; the twin's adjustments take view-space regions today); every adjustment kind with alpha, own and folder masks and opacity, blurs at the drawn scale and direction (`54dd707`); a moved or duplicated selection's preview and commit (the hole cut only for a move, the rounded offset, opacity and coverage once) and the lazy tiles realised before display and commit, a failure cancelling the move with `brushError` (`4b04323`); live paint, erase and mask paint replacing patch interiors, revealing paint past the old bounds under the old mask, gradients' selection clip and opacity matching the commit, the lazy gradient fill realised before display and commit, Smudge and Liquify previews keeping mask, opacity and placement (`5708ed3`, `91cb0b1`); a new one-pixel white mask revealing its whole layer, rotated or scaled without a faded edge, and the live paint grid's extent kept (`93de9e7`); rotation, flips and scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom (`cdf8674`); non-adjacent live mask sources, distortion previews (perspective, folded, flipped, placed masks, effects), text previews keeping effects and handing them on without a flash, new drafts above the active layer inside a clipping stack, lines above the image, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`f773f7b`); sharp reductions and fresh paint pixels (`4c5e1a6`, `d04f159`); `b1b7490` only words a test's diagnostics. Also named, each to judge: sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on ordinary pixel layers, and filter, Levels and Hue/Saturation previews replacing the layer's pixels with its alpha, mask, opacity and placement (`cdf8674`); image-edge sampling that carries edge values outward for filtering and keeps the image's own boundary, for ordinary images and live grids as for the white mask (`93de9e7`); adjustment results equal to the CPU reference, deterministic Noise and Grain seeds, Add Noise counted in frame pixels from the document's corner and Grain's size in document pixels (`54dd707`); a lazy gradient fill that fails cancelling the gradient with `brushError`, and a failed commit publishing it (`5708ed3`); Smudge and Liquify strokes and commits equal to the CPU algorithms (`91cb0b1`, `matchesTheCPU`: the twin's CPU path is the reference, a held judgment); new text shown when the active layer cannot draw, the pixel grid and the new text frame drawn above the image and passing input through, and edits keeping mask gray values and effects and placed masks as paint bounds grow (`f773f7b`). Texture retention, eviction and blits are GPU mechanisms with no CPU twin. +- 13.9b port (agent): Grain and Add Noise anchored to the document on the canvas (`adjustmentRegion`); every other GPU contract judged held, gaps pinned in `AdjustmentCanvasTests` and `CanvasEditContractTests`; the grid over the border hairline left as a known difference. No review yet. - [ ] 13.10 Part three: Shift keeps moved pixels on a line (`60bde4f`); Ctrl+A selects the canvas from the Layers panel (`451281e`). - [ ] 13.11 Version 1.4.1, part one: why a brush can't paint, Select All then Inverse deselects (`133c34a`); painting at the layer's own resolution (`d3170ab`): Clone Stamp and Blur sample there, the Brush and eraser on a scaled-down, rotated or unequally scaled layer that holds pixels lay a tip of the layer's own pixels and show a smooth edge scaled back up, while an empty layer, however small its transform, paints at document resolution (`paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution`); the twin's coverage in the stroke's grid (`BrushStroke::grid`, `BrushCoverage::render`) may already hold both, a held judgment to prove with `NativeResolutionPaintTests`' cases; Cancel and Apply only when an edit waits, scaled layers' scale (`67cc31e`). - [ ] 13.12 Part two: masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (`b3419ab`). From 0b0d36a7dffa20b0c67872e248724ae1232cf1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= <zhangyanghaha0407@outlook.com> Date: Thu, 1 Oct 2026 18:04:14 -0400 Subject: [PATCH 14/17] Masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (13.12) --- CMakeLists.txt | 2 + src/ContentView.cpp | 14 +- src/ContentView.h | 3 + src/Document/EditorSession+Projects.cpp | 4 +- src/Document/EditorSession.cpp | 19 +- src/Document/EditorSession.h | 7 +- src/Document/Filters+Session.cpp | 2 +- src/Document/LayerEffects+Session.cpp | 2 +- src/Document/LayerMask+Alone.cpp | 21 ++ src/Document/LayerMask+Session.h | 6 + src/Document/LayerMask.cpp | 15 +- src/Rendering/EditorCanvas+MaskAlone.cpp | 23 ++ src/Rendering/EditorCanvas.cpp | 7 +- src/Rendering/EditorCanvas.h | 4 + src/UI/LayerIcons.cpp | 6 + src/UI/LayerIcons.h | 2 +- src/UI/LayerMaskMenu.cpp | 60 ++++- src/UI/LayerMaskMenu.h | 17 +- src/UI/NativeLayerList+Buttons.cpp | 14 +- src/UI/NativeLayerList+Cell.cpp | 4 +- src/UI/NativeLayerList.h | 5 +- tests/LayerDragGestureTests.cpp | 12 +- tests/LayerListFocusTests.cpp | 10 +- tests/LayersPanelTests.cpp | 36 ++- tests/MaskAloneTests.cpp | 298 +++++++++++++++++++++++ tests/NativeLayerListTests.cpp | 2 +- tests/SelectionEditTests.cpp | 30 +-- tests/Tests.cmake | 1 + 28 files changed, 554 insertions(+), 72 deletions(-) create mode 100644 src/Document/LayerMask+Alone.cpp create mode 100644 src/Rendering/EditorCanvas+MaskAlone.cpp create mode 100644 tests/MaskAloneTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8983403..bc6c590 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,7 @@ add_library(compositor_core STATIC src/Document/LayerAppearance.cpp src/Document/LayerGroups.cpp src/Document/LayerMask.cpp + src/Document/LayerMask+Alone.cpp src/Document/LayerTransform.cpp src/Document/LiveLayerMask.cpp src/Document/PixelAdjust.cpp @@ -202,6 +203,7 @@ add_library(compositor_core STATIC src/Rendering/EditorCanvas+Gradient.cpp src/Rendering/EditorCanvas+Cursors.cpp src/Rendering/BrushCursorOverlay.cpp + src/Rendering/EditorCanvas+MaskAlone.cpp src/Rendering/EditorCanvas+Navigation.cpp src/Rendering/EditorCanvas+Picking.cpp src/Rendering/EditorCanvas+Selection.cpp diff --git a/src/ContentView.cpp b/src/ContentView.cpp index f10ade0..16dcf68 100644 --- a/src/ContentView.cpp +++ b/src/ContentView.cpp @@ -11,6 +11,7 @@ #include "UI/ShapeControls.h" #include "UI/TypeControls.h" #include "UI/LassoControls.h" +#include "UI/LayerMaskMenu.h" #include "UI/NavigationToolHeader.h" #include "UI/TransformInspector.h" #include "UI/NewCanvasSheet.h" @@ -196,7 +197,8 @@ protected: 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_dock(new QWidget(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_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)), + m_maskAloneBadge(new MaskAloneBadge(session, 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)) { @@ -240,6 +242,12 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW // The welcome sits over the canvas, as Swift's ZStack. m_canvasSlot->addWidget(m_canvas, 0, 0); m_canvasSlot->addWidget(m_dropRing, 0, 0); + // A layout takes no clicks; the canvas keeps the rest. + auto *foot = new QVBoxLayout; + foot->addStretch(1); + foot->addWidget(m_maskAloneBadge, 0, Qt::AlignHCenter); + foot->addSpacing(14); + m_canvasSlot->addLayout(foot, 0, 0); setAcceptDrops(true); auto *middle = new QHBoxLayout; @@ -390,6 +398,10 @@ void ContentView::synchronize() } const std::optional<CanvasDocument> &document = m_session.document(); showWelcome(!document); + const std::optional<ImageLayer> alone = m_session.maskAloneLayer(); + if (alone) + m_maskAloneBadge->setLayerName(alone->name); + m_maskAloneBadge->setVisible(alone.has_value()); for (QWidget *ruler : m_rulers) ruler->setVisible(m_session.showsRulers() && document); m_zoom->setVisible(document.has_value()); diff --git a/src/ContentView.h b/src/ContentView.h index bbe4115..46b6081 100644 --- a/src/ContentView.h +++ b/src/ContentView.h @@ -14,6 +14,7 @@ #include <QWidget> class LayersPanel; +class MaskAloneBadge; class ToolButton; class ProjectWorkspace; class QFileDialog; @@ -74,6 +75,8 @@ private: QWidget *const m_dock; // An accent ring while a drop may land. QWidget *const m_dropRing; + // Over the canvas's foot while a mask shows alone. + MaskAloneBadge *const m_maskAloneBadge; // Swift 1.1.7's rulers: the corner, then the two strips. std::vector<QWidget *> m_rulers; ProjectWorkspace *m_workspace = nullptr; diff --git a/src/Document/EditorSession+Projects.cpp b/src/Document/EditorSession+Projects.cpp index d465ef5..6f197ff 100644 --- a/src/Document/EditorSession+Projects.cpp +++ b/src/Document/EditorSession+Projects.cpp @@ -27,7 +27,7 @@ std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const void EditorSession::installProject(const ProjectSnapshot &snapshot, const QString &path) { m_collapsedGroupIDs.clear(); - m_isMaskSelected = false; + setIsMaskSelected(false); m_cropRect.reset(); m_guideDrag = std::nullopt; m_transformEdit = std::nullopt; @@ -78,7 +78,7 @@ void EditorSession::setProjectPath(std::optional<QString> path) void EditorSession::clearProject() { m_collapsedGroupIDs.clear(); - m_isMaskSelected = false; + setIsMaskSelected(false); m_cropRect.reset(); m_transformEdit = std::nullopt; m_guideDrag = std::nullopt; diff --git a/src/Document/EditorSession.cpp b/src/Document/EditorSession.cpp index 3c541b3..d2db7b2 100644 --- a/src/Document/EditorSession.cpp +++ b/src/Document/EditorSession.cpp @@ -35,7 +35,7 @@ void EditorSession::setActiveLayerID(std::optional<QUuid> id) { // Another layer: its pixels are the target again. if (id != m_activeLayerID) - m_isMaskSelected = false; + setIsMaskSelected(false); m_activeLayerID = id; m_selectedLayerIDs = id ? QSet<QUuid>{*id} : QSet<QUuid>(); notify(); @@ -234,7 +234,7 @@ void EditorSession::restore(const DocumentHistory::Snapshot &snapshot) m_document = snapshot.document; setActiveLayerID(snapshot.activeLayerID); const std::optional<ImageLayer> active = activeLayer(); - m_isMaskSelected = keepsMaskTarget && active && active->mask; + setIsMaskSelected(keepsMaskTarget && active && active->mask); if (changedCanvas && m_document) viewport.fit(m_document->size()); qCInfo(lcApp) << "restored a history snapshot of" << (m_document ? int(m_document->layers.size()) : 0) << "layers"; @@ -381,3 +381,18 @@ void EditorSession::requestCanvasFocus() ++m_canvasFocusRequest; notify(); } + +void EditorSession::setIsMaskSelected(bool value) +{ + m_isMaskSelected = value; + if (!value) + m_viewsMaskAlone = false; +} + +void EditorSession::setViewsMaskAlone(bool value) +{ + if (m_viewsMaskAlone == value) + return; + m_viewsMaskAlone = value; + notify(); +} diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 4e237ea..487d22b 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -93,10 +93,11 @@ public: void selectTool(NavigationTool value); // Tab: the current tool's next mode. void cycleToolMode(); - // Whether the mask thumbnail, not the layer's, is the target. bool isMaskSelected() const { return m_isMaskSelected; } - + // Alt-click: the targeted mask alone, gray, on the canvas. + bool viewsMaskAlone() const { return m_viewsMaskAlone; } + void setViewsMaskAlone(bool value); // A blend mode tried on by hovering; nothing is saved. struct BlendPreview { QUuid layerID; @@ -132,7 +133,6 @@ public: // An unlinked mask, selected, transforms without its layer. bool transformTargetsMask() const; void nudgeLayer(double dx, double dy); - const std::optional<LassoDraft> &lassoDraft() const { return m_lassoDraft; } LassoKind lassoKind() const { return m_lassoKind; } void setLassoKind(LassoKind kind); @@ -350,6 +350,7 @@ private: std::optional<BlendPreview> m_blendPreview; std::optional<QUuid> m_opacityEditLayerID; bool m_isMaskSelected = false; + bool m_viewsMaskAlone = false; std::optional<QUuid> m_activeLayerID; bool m_isProjectBusy = false; bool m_showsNewDocument = false; diff --git a/src/Document/Filters+Session.cpp b/src/Document/Filters+Session.cpp index fe4dbe9..94c5da2 100644 --- a/src/Document/Filters+Session.cpp +++ b/src/Document/Filters+Session.cpp @@ -351,7 +351,7 @@ void EditorSession::finishFilterCommit() mask.isEnabled = true; mask.placement = std::nullopt; m_document->layers[index].mask = mask; - m_isMaskSelected = true; + setIsMaskSelected(true); endEdit(); finish(); return; diff --git a/src/Document/LayerEffects+Session.cpp b/src/Document/LayerEffects+Session.cpp index 0cf1996..e68353d 100644 --- a/src/Document/LayerEffects+Session.cpp +++ b/src/Document/LayerEffects+Session.cpp @@ -97,7 +97,7 @@ void EditorSession::selectEffect(LayerEffectKind kind, QUuid id, bool editing) finishEffectsEditing(false); // Swift's `selectedLayerIDs = [id]`: selectLayer sets it, unrefused here. selectLayer(id); - m_isMaskSelected = false; + setIsMaskSelected(false); m_effectSelection = selection; if (editing && m_effectsEditing != selection) { m_effectsEditingOriginal = m_document->layers[size_t(indexOf(m_document->layers, id))].effects.value_or(LayerEffects()); diff --git a/src/Document/LayerMask+Alone.cpp b/src/Document/LayerMask+Alone.cpp new file mode 100644 index 0000000..64c8841 --- /dev/null +++ b/src/Document/LayerMask+Alone.cpp @@ -0,0 +1,21 @@ +#include "Document/EditorSession.h" + +// Swift's mask shown alone, an Alt-click on its thumbnail. +std::optional<ImageLayer> EditorSession::maskAloneLayer() const +{ + const std::optional<ImageLayer> layer = activeLayer(); + if (!m_viewsMaskAlone || !m_isMaskSelected || !layer || !layer->mask) + return std::nullopt; + return layer; +} + +void EditorSession::toggleMaskAlone(QUuid id) +{ + const std::optional<ImageLayer> shown = maskAloneLayer(); + const bool showing = shown && shown->id == id; + selectLayerTarget(id, true); + if (m_activeLayerID != id || !m_isMaskSelected) + return; + m_viewsMaskAlone = !showing; + notify(); +} diff --git a/src/Document/LayerMask+Session.h b/src/Document/LayerMask+Session.h index 0b26b75..4795105 100644 --- a/src/Document/LayerMask+Session.h +++ b/src/Document/LayerMask+Session.h @@ -15,6 +15,12 @@ public: std::optional<QImage> maskDistortPreview(const ImageLayer &layer) const; // The selection made a mask; without one, a solid mask. void addMask(bool revealing = true); + // The layer whose mask the canvas shows alone, if any. + std::optional<ImageLayer> maskAloneLayer() const; + // Alt-click on a mask thumbnail: alone, or the composite again. + void toggleMaskAlone(QUuid id); private: + // Swift's didSet: the pixels targeted end the mask view. + void setIsMaskSelected(bool value); void commitMaskTransform(); diff --git a/src/Document/LayerMask.cpp b/src/Document/LayerMask.cpp index 0e0013e..7642a75 100644 --- a/src/Document/LayerMask.cpp +++ b/src/Document/LayerMask.cpp @@ -262,7 +262,7 @@ void EditorSession::selectLayerTarget(QUuid id, bool mask) resolveGradient(); selectLayer(id); const std::optional<ImageLayer> active = activeLayer(); - m_isMaskSelected = mask && active && active->mask; + setIsMaskSelected(mask && active && active->mask); notify(); } @@ -286,14 +286,15 @@ void EditorSession::addMask(bool revealing) // The selection's coverage, soft where it is feathered. QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height, BrushRaster::pixelToDocument(active->transform, width, height)); - if (revealing) + // Revealing, white inside; hiding, black inside. + if (!revealing) image.invertPixels(); const LayerMask mask(LayerMask::assetFrom(image)); finishOpacityEdit(); - beginEdit(QStringLiteral("Add Mask from Selection")); + beginEdit(revealing ? QStringLiteral("Reveal Selection") : QStringLiteral("Hide Selection")); m_document->layers[size_t(index)].mask = mask; m_document->selection = std::nullopt; - m_isMaskSelected = true; + setIsMaskSelected(true); endEdit(); } catch (const std::runtime_error &error) { qCWarning(lcApp).noquote() << "cannot make a mask from the selection:" << error.what(); @@ -312,7 +313,7 @@ void EditorSession::addLayerMask(bool revealing) if (layer.id == active->id) layer.mask = LayerMask::solid(revealing); } - m_isMaskSelected = true; + setIsMaskSelected(true); endEdit(); } @@ -341,7 +342,7 @@ void EditorSession::deleteLayerMask() if (layer.id == active->id) layer.mask = std::nullopt; } - m_isMaskSelected = false; + setIsMaskSelected(false); endEdit(); } @@ -371,7 +372,7 @@ void EditorSession::copyMask(QUuid source, QUuid target) beginEdit(find(target)->mask ? QStringLiteral("Replace Layer Mask") : QStringLiteral("Copy Layer Mask")); find(target)->mask = mask; selectLayer(target); - m_isMaskSelected = true; + setIsMaskSelected(true); endEdit(); } diff --git a/src/Rendering/EditorCanvas+MaskAlone.cpp b/src/Rendering/EditorCanvas+MaskAlone.cpp new file mode 100644 index 0000000..5b9ae63 --- /dev/null +++ b/src/Rendering/EditorCanvas+MaskAlone.cpp @@ -0,0 +1,23 @@ +#include "Rendering/EditorCanvas.h" + +// Swift's drawMaskAlone: gray across the canvas, edge tone past it. +void CanvasView::drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center ¢er, QPainter &context) +{ + const LayerMask &mask = layer.mask.value(); + const int tone = qRound(LayerMask::background(mask.asset.thumbnail) * 255); + context.fillRect(QRectF(center(QPointF(0, 0)), document.size() * scale), QColor(tone, tone, tone)); + const std::optional<GradientEdit> &gradient = m_session.gradientEdit(); + const BrushStroke *stroke = m_session.brushStroke() ? m_session.brushStroke() : gradient ? gradient->raster.get() : nullptr; + // A stroke or gradient laid into it shows at once. + if (stroke && stroke->isMask && stroke->layer.id == layer.id) { + const LayerTransform &placed = stroke->paintTransform; + const std::shared_ptr<const RasterSnapshot> &raster = mask.asset.raster; + LayerRenderer::drawBrushPreview(raster ? QImage() : mask.asset.image(), placed, center(placed.center()), context, {.scale = scale}, + {.patches = stroke->patches(), .pixelWidth = stroke->width, .pixelHeight = stroke->height, + .paintingMask = false, .sourceRect = stroke->sourceRect, .raster = raster}); + return; + } + // Where a transform drag shows it, as the composite. + const LayerTransform placed = m_session.displayedMaskPlacement(layer).value_or(m_session.displayedTransform(layer)); + LayerRenderer::draw(mask.asset.image(), placed, center(placed.center()), context, {.scale = scale}); +} diff --git a/src/Rendering/EditorCanvas.cpp b/src/Rendering/EditorCanvas.cpp index c9bb0a1..2b44c96 100644 --- a/src/Rendering/EditorCanvas.cpp +++ b/src/Rendering/EditorCanvas.cpp @@ -85,7 +85,8 @@ CanvasView::DisplayState CanvasView::displayState() const .layers = {}, .folderMasks = {}, .textStyle = m_session.textDraft() ? std::optional(m_session.textDraft()->style) : std::nullopt, - .textTransform = m_session.textDraft() && m_inlineTextEditor ? std::optional(m_inlineTextEditor->shownTransform()) : std::nullopt}; + .textTransform = m_session.textDraft() && m_inlineTextEditor ? std::optional(m_inlineTextEditor->shownTransform()) : std::nullopt, + .maskAlone = m_session.maskAloneLayer() ? std::optional(m_session.maskAloneLayer()->mask->asset.identity()) : std::nullopt}; if (!document) return state; // A hidden source still clips: live masks count every layer. @@ -299,6 +300,10 @@ FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit, void CanvasView::drawLayers(const CanvasDocument &document, double scale, const Center ¢er, QPainter &context) { + if (const std::optional<ImageLayer> alone = m_session.maskAloneLayer()) { + drawMaskAlone(*alone, document, scale, center, context); + return; + } handOnDraftEffects(document); m_session.effectsPreviews.prepare(document.layers); std::map<QUuid, ImageLayer> byID; diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h index 1f15405..c8d1bd4 100644 --- a/src/Rendering/EditorCanvas.h +++ b/src/Rendering/EditorCanvas.h @@ -122,6 +122,8 @@ private: // Typed text, which the canvas draws as pixels. std::optional<LayerTextStyle> textStyle; std::optional<LayerTransform> textTransform; + // The mask shown alone, though the composite may skip it. + std::optional<ImageIdentity> maskAlone; friend bool operator==(const DisplayState &, const DisplayState &) = default; }; // A Zoom press: drags zoom about it, clicks step. @@ -146,6 +148,8 @@ private: void drawStroke(const BrushStroke &stroke, const ImageLayer &layer, const LayerTransform &transform, const std::optional<QImage> &mask, const LayerRenderer::Options &options, double scale, const Center ¢er, QPainter &target); void drawLayers(const CanvasDocument &document, double scale, const Center ¢er, QPainter &context); + // Alt-click's view: the mask in gray, strokes included. + void drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center ¢er, QPainter &context); // Typed text as pixels, where the editor shows it. struct DraftText { QImage image; diff --git a/src/UI/LayerIcons.cpp b/src/UI/LayerIcons.cpp index 0c2ae56..3783484 100644 --- a/src/UI/LayerIcons.cpp +++ b/src/UI/LayerIcons.cpp @@ -313,6 +313,12 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double case LayerIcon::dottedCircle: dottedCircle(painter); break; case LayerIcon::plusMagnifyingGlass: magnifyingGlass(painter, true); break; case LayerIcon::minusMagnifyingGlass: magnifyingGlass(painter, false); break; + // Swift's xmark at bold weight. + case LayerIcon::xmark: + painter.setPen(QPen(colour, 2.6, Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(QPointF(4, 4), QPointF(14, 14)); + painter.drawLine(QPointF(14, 4), QPointF(4, 14)); + break; } painter.restore(); } diff --git a/src/UI/LayerIcons.h b/src/UI/LayerIcons.h index 267ca7a..ea0d660 100644 --- a/src/UI/LayerIcons.h +++ b/src/UI/LayerIcons.h @@ -8,7 +8,7 @@ enum class LayerIcon { eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text, halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes, - scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass + scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass, xmark }; namespace LayerIcons { diff --git a/src/UI/LayerMaskMenu.cpp b/src/UI/LayerMaskMenu.cpp index 22bb295..7e58d7d 100644 --- a/src/UI/LayerMaskMenu.cpp +++ b/src/UI/LayerMaskMenu.cpp @@ -1,6 +1,9 @@ #include "UI/LayerMaskMenu.h" #include "UI/LayerIcons.h" #include <QEvent> +#include <QGuiApplication> +#include <QHBoxLayout> +#include <QPainter> LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolButton(parent), m_session(session) { @@ -8,7 +11,8 @@ LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolBut setAccessibleName(QStringLiteral("Add layer mask")); setAutoRaise(true); setFixedSize(32, 40); - connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(); }); + // Swift reads Option from the event that clicked. + connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(!QGuiApplication::keyboardModifiers().testFlag(Qt::AltModifier)); }); connect(&m_session, &EditorSession::changed, this, &LayerMaskMenu::synchronize); synchronize(); } @@ -17,7 +21,8 @@ void LayerMaskMenu::synchronize() { const std::optional<ImageLayer> active = m_session.activeLayer(); setEnabled(m_session.canEditMask() && !(active && active->mask)); - setToolTip(m_session.selection() ? QStringLiteral("Add layer mask (the selection becomes black)") : QStringLiteral("Add layer mask")); + setToolTip(m_session.selection() ? QStringLiteral("Add layer mask revealing the selection (Alt-click to hide it)") + : QStringLiteral("Add layer mask (Alt-click for a black mask)")); setIcon(LayerIcons::pixmap(LayerIcon::addMask, 16, palette().color(QPalette::PlaceholderText), devicePixelRatio())); } @@ -27,3 +32,54 @@ void LayerMaskMenu::changeEvent(QEvent *event) if (event->type() == QEvent::PaletteChange) synchronize(); } + +MaskAloneBadge::MaskAloneBadge(EditorSession &session, QWidget *parent) : QWidget(parent), m_name(new QLabel(this)) +{ + setObjectName(QStringLiteral("maskAloneBadge")); + setFixedHeight(26); + const auto font = [](QLabel *label, bool semibold, double alpha) { + QFont face = label->font(); + face.setPixelSize(12); + face.setWeight(semibold ? QFont::DemiBold : QFont::Normal); + label->setFont(face); + QPalette colours = label->palette(); + colours.setColor(QPalette::WindowText, QColor::fromRgbF(1, 1, 1, alpha)); + label->setPalette(colours); + }; + auto *icon = new QLabel(this); + icon->setPixmap(LayerIcons::pixmap(LayerIcon::addMask, 11, Qt::white, devicePixelRatioF())); + auto *title = new QLabel(QStringLiteral("Layer Mask"), this); + title->setObjectName(QStringLiteral("maskAloneTitle")); + font(title, true, 1); + m_name->setObjectName(QStringLiteral("maskAloneName")); + m_name->setTextFormat(Qt::PlainText); + m_name->setMaximumWidth(220); + font(m_name, false, 0.6); + auto *close = new QToolButton(this); + close->setObjectName(QStringLiteral("maskAloneClose")); + close->setAutoRaise(true); + close->setIcon(LayerIcons::pixmap(LayerIcon::xmark, 9, Qt::white, devicePixelRatioF())); + close->setToolTip(QStringLiteral("Show the image again (or Alt-click the mask thumbnail)")); + close->setAccessibleName(QStringLiteral("Stop viewing the mask")); + connect(close, &QToolButton::clicked, this, [&session] { session.setViewsMaskAlone(false); }); + auto *row = new QHBoxLayout(this); + row->setContentsMargins(11, 0, 8, 0); + row->setSpacing(7); + for (QWidget *widget : std::initializer_list<QWidget *>{icon, title, m_name, close}) + row->addWidget(widget); +} + +// Swift's byTruncatingTail within 220 points. +void MaskAloneBadge::setLayerName(const QString &name) +{ + m_name->setText(m_name->fontMetrics().elidedText(name, Qt::ElideRight, 220)); +} + +void MaskAloneBadge::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.14), 1)); + painter.setBrush(QColor::fromRgbF(0, 0, 0, 0.75)); + painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 13, 13); +} diff --git a/src/UI/LayerMaskMenu.h b/src/UI/LayerMaskMenu.h index 1a2cd89..08dd120 100644 --- a/src/UI/LayerMaskMenu.h +++ b/src/UI/LayerMaskMenu.h @@ -1,8 +1,9 @@ #pragma once #include "Document/EditorSession.h" +#include <QLabel> #include <QToolButton> -// One click adds a mask: white, or the selection black. +// One click adds a mask revealing the selection; Alt, hiding. class LayerMaskMenu : public QToolButton { Q_OBJECT public: @@ -16,3 +17,17 @@ private: EditorSession &m_session; }; + +// While a mask shows alone: whose, and a way back. +class MaskAloneBadge : public QWidget { + Q_OBJECT +public: + explicit MaskAloneBadge(EditorSession &session, QWidget *parent = nullptr); + void setLayerName(const QString &name); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + QLabel *const m_name; +}; diff --git a/src/UI/NativeLayerList+Buttons.cpp b/src/UI/NativeLayerList+Buttons.cpp index 10880c2..79b8637 100644 --- a/src/UI/NativeLayerList+Buttons.cpp +++ b/src/UI/NativeLayerList+Buttons.cpp @@ -70,9 +70,10 @@ LayerThumbnailButton::LayerThumbnailButton(NativeLayerList &list, bool maskTarge setAutoRaise(true); } -void LayerThumbnailButton::setTargeted(bool targeted) +void LayerThumbnailButton::setTargeted(bool targeted, bool alone) { m_targeted = targeted; + m_alone = alone; QWidget::update(); } @@ -155,12 +156,9 @@ void LayerThumbnailButton::mouseReleaseEvent(QMouseEvent *event) if (!m_altMaskPress || event->button() != Qt::LeftButton) return; endAltMaskPress(); - // Released where pressed: a click, not a drag; Shift toggles. - if (!rect().contains(event->position().toPoint())) - return; - m_list.session().selectLayerTarget(layerID, true); - if (event->modifiers().testFlag(Qt::ShiftModifier)) - m_list.session().toggleLayerMask(); + // Released where pressed: the mask alone, or the image again. + if (rect().contains(event->position().toPoint())) + m_list.session().toggleMaskAlone(layerID); } // With a modifier the second press is the button's again. @@ -179,7 +177,7 @@ void LayerThumbnailButton::paintEvent(QPaintEvent *) if (!m_targeted) return; painter.setRenderHint(QPainter::Antialiasing); - painter.setPen(QPen(palette().color(QPalette::Highlight), 2)); + painter.setPen(QPen(m_alone ? QColor(Qt::white) : palette().color(QPalette::Highlight), 2)); painter.setBrush(Qt::NoBrush); painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 3, 3); } diff --git a/src/UI/NativeLayerList+Cell.cpp b/src/UI/NativeLayerList+Cell.cpp index 54a7a59..b70fbff 100644 --- a/src/UI/NativeLayerList+Cell.cpp +++ b/src/UI/NativeLayerList+Cell.cpp @@ -31,7 +31,7 @@ LayerCell::LayerCell(NativeLayerList &list) connect(m_disclosure, &QToolButton::clicked, this, [this] { m_list.session().toggleGroupExpansion(m_layerID); }); m_thumbnail->setObjectName(QStringLiteral("layerThumbnail")); m_maskThumbnail->setObjectName(QStringLiteral("maskThumbnail")); - m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); + m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); m_link->setObjectName(QStringLiteral("maskLink")); m_link->setAutoRaise(true); m_link->setFixedSize(9, 20); @@ -159,7 +159,7 @@ void LayerCell::updateTarget() const bool active = session.activeLayerID() == m_layerID && session.selectedLayerIDs().size() == 1; const bool mask = session.isMaskSelected(); m_thumbnail->setTargeted(active && !mask); - m_maskThumbnail->setTargeted(active && mask); + m_maskThumbnail->setTargeted(active && mask, active && session.maskAloneLayer()); QWidget::update(); } diff --git a/src/UI/NativeLayerList.h b/src/UI/NativeLayerList.h index b672fc4..69e6831 100644 --- a/src/UI/NativeLayerList.h +++ b/src/UI/NativeLayerList.h @@ -43,8 +43,8 @@ public: LayerThumbnailButton(NativeLayerList &list, bool maskTarget, QWidget *parent); QUuid layerID; const bool isMaskTarget; - // An accent ring while the target is selected. - void setTargeted(bool targeted); + // An accent ring while targeted; white while shown alone. + void setTargeted(bool targeted, bool alone = false); bool isTargeted() const { return m_targeted; } ~LayerThumbnailButton() override; @@ -63,6 +63,7 @@ private: NativeLayerList &m_list; bool m_targeted = false; + bool m_alone = false; // Alt on a mask: chosen on release, or dragged. std::optional<QPoint> m_altMaskPress; }; diff --git a/tests/LayerDragGestureTests.cpp b/tests/LayerDragGestureTests.cpp index 990cec9..2b11f81 100644 --- a/tests/LayerDragGestureTests.cpp +++ b/tests/LayerDragGestureTests.cpp @@ -36,7 +36,7 @@ private slots: void hoverOverRowsAndCtrlKeepTheCursorRight(); void aSpentPressArmsNothingLater(); void aMaskPressReleasedInAPopupIsOver(); - void altClicksOnAMaskThroughTheWindowChooseAndToggle(); + void altClicksOnAMaskThroughTheWindowShowItAloneAndBack(); void anotherButtonsReleaseLeavesAMaskPress(); }; @@ -260,7 +260,7 @@ void LayerDragGestureTests::aMaskPressReleasedInAPopupIsOver() QCOMPARE(startedDrags.back().rows, uuidString(shown.id(0)).toUtf8()); } -void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowChooseAndToggle() +void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowShowItAloneAndBack() { const auto session = sessionWithLayers(2); Shown shown(*session); @@ -272,11 +272,11 @@ void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowChooseAndToggle() const QPoint at = mask.mapTo(&shown.list, QPoint(5, 5)); // The window sees the release first; the click still lands. QTest::mouseClick(shown.list.windowHandle(), Qt::LeftButton, Qt::AltModifier, at); - QVERIFY(session->isMaskSelected()); - QVERIFY(session->activeLayer().value().mask.value().isEnabled); + QVERIFY(session->isMaskSelected() && session->viewsMaskAlone()); + // Again, the image; Shift no longer toggles the mask. QTest::mouseClick(shown.list.windowHandle(), Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, at); - QVERIFY(session->isMaskSelected()); - QVERIFY(!session->activeLayer().value().mask.value().isEnabled); + QVERIFY(session->isMaskSelected() && !session->viewsMaskAlone()); + QVERIFY(session->activeLayer().value().mask.value().isEnabled); QCOMPARE(startedDrags.size(), size_t(0)); } diff --git a/tests/LayerListFocusTests.cpp b/tests/LayerListFocusTests.cpp index 0b33b3f..01d81f7 100644 --- a/tests/LayerListFocusTests.cpp +++ b/tests/LayerListFocusTests.cpp @@ -62,7 +62,7 @@ private slots: void tabAndTheBrushKeysStayWithTheList(); void aSliderDragReleasedOverAPopupStillEnds(); void anotherButtonsReleaseLeavesTheSwipe(); - void altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAMask(); + void altDoubleClickOnTheStripClipsTwiceAndAltShowsAMaskAlone(); }; void LayerListFocusTests::aMenuBorrowingFocusKeepsTheRenameAndTheField() @@ -321,7 +321,7 @@ void LayerListFocusTests::anotherButtonsReleaseLeavesTheSwipe() QVERIFY(session->canUndo()); } -void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAMask() +void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShowsAMaskAlone() { const auto session = sessionWithLayers(2); NativeLayerList list(*session); @@ -339,13 +339,13 @@ void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAM session->undo(); QCOMPARE(layerWith(*session, top.layerID()).maskSourceID, std::optional(list.cells().at(1)->layerID())); session->redo(); - // Alt+Shift on a mask chooses it and toggles it. + // Alt on a mask shows it alone; Shift adds nothing. session->selectLayers({top.layerID()}, top.layerID()); session->addLayerMask(); session->selectLayerTarget(top.layerID(), false); QTest::mouseClick(&top.maskThumbnail(), Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, QPoint(5, 5)); - QVERIFY(session->isMaskSelected()); - QVERIFY(!session->activeLayer().value().mask.value().isEnabled); + QVERIFY(session->isMaskSelected() && session->viewsMaskAlone()); + QVERIFY(session->activeLayer().value().mask.value().isEnabled); } void LayerListFocusTests::tabAndTheBrushKeysStayWithTheList() diff --git a/tests/LayersPanelTests.cpp b/tests/LayersPanelTests.cpp index 1b1bb01..fecb7a8 100644 --- a/tests/LayersPanelTests.cpp +++ b/tests/LayersPanelTests.cpp @@ -188,22 +188,36 @@ void LayersPanelTests::theMaskButtonUsesTheSelection() panel.show(); QVERIFY(QTest::qWaitForWindowExposed(&panel)); auto &mask = find<QToolButton>(panel, "addLayerMask"); - QCOMPARE(mask.toolTip(), QString("Add layer mask")); + QCOMPARE(mask.toolTip(), QString("Add layer mask (Alt-click for a black mask)")); + // Alt-click: all black without a selection. + QTest::mouseClick(&mask, Qt::LeftButton, Qt::AltModifier); + QCOMPARE(session.history.undoName(), QString("Add Hide-All Mask")); + session.undo(); + QTest::mouseClick(&mask, Qt::LeftButton); + QCOMPARE(session.history.undoName(), QString("Add Reveal-All Mask")); + session.undo(); QPainterPath box; box.addRect(QRectF(20, 10, 30, 20)); session.applySelection(box, SelectionMode::replace, "Select"); - QCOMPARE(mask.toolTip(), QString("Add layer mask (the selection becomes black)")); + QCOMPARE(mask.toolTip(), QString("Add layer mask revealing the selection (Alt-click to hide it)")); + // The button reveals the selection, as Photoshop's. QTest::mouseClick(&mask, Qt::LeftButton); - QCOMPARE(session.history.undoName(), QString("Add Mask from Selection")); + QCOMPARE(session.history.undoName(), QString("Reveal Selection")); QVERIFY(!session.selection().has_value() && session.isMaskSelected() && !mask.isEnabled()); - QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0); - QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); - // The row's menu: black shows only the selection. - session.undo(); - QVERIFY(session.selection().has_value()); - LayerCell &cell = *panel.list().cells().at(0); - panel.list().menuFor(cell, QPoint(cell.width() - 20, 20))->findChild<QAction *>("addBlackMask")->trigger(); - QCOMPARE(session.history.undoName(), QString("Add Mask from Selection")); QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 255); QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 0); + // Alt-click hides it instead. + session.undo(); + QVERIFY(session.selection().has_value()); + QTest::mouseClick(&mask, Qt::LeftButton, Qt::AltModifier); + QCOMPARE(session.history.undoName(), QString("Hide Selection")); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); + // The row's Hide All hides the selection too. + session.undo(); + LayerCell &cell = *panel.list().cells().at(0); + panel.list().menuFor(cell, QPoint(cell.width() - 20, 20))->findChild<QAction *>("addBlackMask")->trigger(); + QCOMPARE(session.history.undoName(), QString("Hide Selection")); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); } diff --git a/tests/MaskAloneTests.cpp b/tests/MaskAloneTests.cpp new file mode 100644 index 0000000..77820c8 --- /dev/null +++ b/tests/MaskAloneTests.cpp @@ -0,0 +1,298 @@ +#include "BrushFixtures.h" +#include "CanvasFixtures.h" +#include "ContentView.h" +#include "UI/LayerMaskMenu.h" +#include "UI/NativeLayerList.h" +#include <QLabel> +#include <QToolButton> + +// Swift's MaskAloneTests: Alt-click shows a mask by itself. +class MaskAloneTests : public QObject { + Q_OBJECT +private slots: + void optionClickTogglesTheMaskViewAndTargetingPixelsEndsIt(); + void aLayerWithoutAMaskHasNothingToShow(); + void anotherLayerOrAnEndedViewShowsTheImage(); + void theCanvasShowsTheMaskInGrayInsteadOfTheImage(); + void aStrokeOrAGradientShowsInTheMaskAsItIsLaid(); + void aPlacedOrDisabledMaskShowsAlone(); + void theBadgeNamesTheMaskAndTakesItsOwnClicks(); + void altClickOnTheThumbnailTogglesItAndOutlinesItWhite(); +}; + +namespace { +// A red layer, 200 by 100, its right half masked. +QUuid masked(EditorSession &session) +{ + session.insert(filled(200, 100, qRgba(255, 0, 0, 255), QStringLiteral("Red"))); + const QUuid id = session.activeLayerID().value(); + QImage mask(200, 100, QImage::Format_Grayscale8); + mask.fill(0); + for (int y = 0; y < 100; ++y) + std::fill_n(mask.scanLine(y), 100, uchar(255)); + rewrite(session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform = LayerTransform{.origin = {0, 0}, .size = {200, 100}}; + setMask(snapshot, id, LayerMask::assetFrom(mask)); + }); + return id; +} + +std::unique_ptr<EditorSession> maskedSession(QUuid &id) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(200, 100); + id = masked(*session); + return session; +} + +// The canvas at 1:1: view points are document pixels. +struct Canvas : Shown { + QUuid id; + Canvas() : Shown(QSize(200, 100), QSize(400, 300)) + { + settle(); + id = masked(session); + session.zoom(1); + canvas->synchronizeDisplay(); + } + QColor at(QPointF point) + { + canvas->synchronizeDisplay(); + const QPointF view = session.viewport.viewPoint(point, documentSize()); + return canvas->grab().toImage().pixelColor(view.toPoint()); + } +}; + +bool red(const QColor &colour) +{ + return colour.red() > 204 && colour.blue() < 102 && colour.green() < 102; +} + +bool gray(const QColor &colour, int value) +{ + return std::abs(colour.red() - value) <= 2 && colour.red() == colour.green() && colour.green() == colour.blue(); +} +} + +void MaskAloneTests::optionClickTogglesTheMaskViewAndTargetingPixelsEndsIt() +{ + QUuid id; + const auto session = maskedSession(id); + session->toggleMaskAlone(id); + QVERIFY(session->maskAloneLayer().value().id == id && session->isMaskSelected() && session->viewsMaskAlone()); + session->toggleMaskAlone(id); + QVERIFY(!session->maskAloneLayer() && session->isMaskSelected()); + session->toggleMaskAlone(id); + session->selectLayerTarget(id, false); + QVERIFY(!session->maskAloneLayer() && !session->viewsMaskAlone()); + session->toggleMaskAlone(id); + session->deleteLayerMask(); + QVERIFY(!session->maskAloneLayer() && !session->viewsMaskAlone()); + session->undo(); + QVERIFY(!session->maskAloneLayer()); +} + +void MaskAloneTests::aLayerWithoutAMaskHasNothingToShow() +{ + QUuid id; + const auto session = maskedSession(id); + session->deleteLayerMask(); + session->toggleMaskAlone(id); + QVERIFY(!session->maskAloneLayer() && !session->isMaskSelected() && !session->viewsMaskAlone()); + // A busy project leaves the target, so the view too. + session->addLayerMask(); + session->selectLayerTarget(id, false); + session->setIsProjectBusy(true); + session->toggleMaskAlone(id); + QVERIFY(!session->viewsMaskAlone()); + // Busy, another layer's targeted mask stays unshown too. + session->setIsProjectBusy(false); + session->addBlankLayer(); + const QUuid other = session->activeLayerID().value(); + session->addLayerMask(); + QVERIFY(session->isMaskSelected()); + session->setIsProjectBusy(true); + session->toggleMaskAlone(id); + QVERIFY(session->activeLayerID() == other && !session->viewsMaskAlone()); +} + +void MaskAloneTests::anotherLayerOrAnEndedViewShowsTheImage() +{ + QUuid id; + const auto session = maskedSession(id); + session->addBlankLayer(); + const QUuid other = session->activeLayerID().value(); + session->toggleMaskAlone(id); + QVERIFY(session->maskAloneLayer().has_value()); + // Another layer targets its pixels: the image again. + session->selectLayer(other); + QVERIFY(!session->maskAloneLayer() && !session->viewsMaskAlone()); + // Alt-clicking another layer's mask shows that one. + session->addLayerMask(false); + session->toggleMaskAlone(id); + session->toggleMaskAlone(other); + QCOMPARE(session->maskAloneLayer().value().id, other); + // The badge's button: each change is announced. + QSignalSpy changed(session.get(), &EditorSession::changed); + session->setViewsMaskAlone(false); + QVERIFY(!session->maskAloneLayer() && session->isMaskSelected()); + QCOMPARE(changed.count(), 1); + session->setViewsMaskAlone(false); + QCOMPARE(changed.count(), 1); +} + +void MaskAloneTests::theCanvasShowsTheMaskInGrayInsteadOfTheImage() +{ + Canvas shown; + QVERIFY(red(shown.at(QPointF(50, 50)))); + shown.session.toggleMaskAlone(shown.id); + QVERIFY(shown.canvas->synchronizeDisplay()); + QVERIFY(gray(shown.at(QPointF(50, 50)), 255)); + QVERIFY(gray(shown.at(QPointF(150, 50)), 0)); + shown.session.toggleMaskAlone(shown.id); + QVERIFY(shown.canvas->synchronizeDisplay()); + QVERIFY(red(shown.at(QPointF(50, 50)))); + // At a crisp zoom too, past the halving. + shown.session.toggleMaskAlone(shown.id); + shown.session.zoom(8); + QVERIFY(gray(shown.at(QPointF(80.5, 50.5)), 255)); + QVERIFY(gray(shown.at(QPointF(120.5, 50.5)), 0)); +} + +void MaskAloneTests::aStrokeOrAGradientShowsInTheMaskAsItIsLaid() +{ + Canvas shown; + EditorSession &session = shown.session; + session.toggleMaskAlone(shown.id); + session.selectTool(NavigationTool::brush); + session.setMaskPaintWhite(false); + session.setBrushSettings(brush(20, 1, 0, 0, 0)); + session.beginBrush(QPointF(50, 50)); + session.continueBrush(QPointF(60, 50)); + QVERIFY(gray(shown.at(QPointF(55, 50)), 0)); + QVERIFY(gray(shown.at(QPointF(55, 80)), 255)); + QVERIFY(session.finishBrushImmediately()); + QVERIFY(gray(shown.at(QPointF(55, 50)), 0)); + QVERIFY(session.maskAloneLayer().has_value()); + // A gradient shows as it is dragged, off its line. + session.selectTool(NavigationTool::gradient); + session.setMaskPaintWhite(true); + session.beginGradient(QPointF(0, 50)); + session.moveGradient(std::nullopt, QPointF(200, 50)); + QVERIFY(session.gradientEdit().has_value()); + const QColor left = shown.at(QPointF(10, 80)), right = shown.at(QPointF(190, 80)); + // Before it, the right half was black. + QVERIFY(gray(right, right.red()) && right.red() > 6 && right.red() < 128); + // What shows is what the gradient commits. + bool done = false; + session.commitGradient([&] { done = true; }); + QTRY_VERIFY(done); + const QImage committed = session.activeLayer().value().mask.value().asset.image(); + QVERIFY(gray(left, committed.pixelColor(10, 80).red()) && gray(right, committed.pixelColor(190, 80).red())); + QVERIFY(gray(shown.at(QPointF(10, 80)), left.red())); +} + +void MaskAloneTests::aPlacedOrDisabledMaskShowsAlone() +{ + Canvas shown; + EditorSession &session = shown.session; + // Turned off, the composite ignores it; alone, it shows. + session.selectLayerTarget(shown.id, true); + session.toggleLayerMask(); + QVERIFY(red(shown.at(QPointF(150, 50)))); + session.toggleMaskAlone(shown.id); + QVERIFY(gray(shown.at(QPointF(150, 50)), 0)); + QVERIFY(gray(shown.at(QPointF(50, 50)), 255)); + // Placed apart, at its placement; its edge tone past it. + rewrite(session, [&](ProjectSnapshot &snapshot) { + record(snapshot, shown.id).maskLinked = false; + record(snapshot, shown.id).maskPlacement = LayerTransform{.origin = {50, 0}, .size = {100, 50}}; + }); + session.toggleMaskAlone(shown.id); + QVERIFY(session.maskAloneLayer().has_value()); + QVERIFY(gray(shown.at(QPointF(60, 20)), 255)); + QVERIFY(gray(shown.at(QPointF(140, 20)), 0)); + const int edge = qRound(LayerMask::background(session.activeLayer().value().mask.value().asset.thumbnail) * 255); + QVERIFY(gray(shown.at(QPointF(20, 80)), edge)); + QVERIFY(edge > 0 && gray(shown.at(QPointF(120, 70)), edge)); +} + +void MaskAloneTests::theBadgeNamesTheMaskAndTakesItsOwnClicks() +{ + EditorSession session; + ContentView view(session); + view.resize(900, 600); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + session.createDocument(200, 100); + const QUuid id = masked(session); + QVERIFY(!view.findChild<MaskAloneBadge *>()->isVisible()); + session.toggleMaskAlone(id); + MaskAloneBadge *badge = nullptr; + QTRY_VERIFY((badge = view.findChild<MaskAloneBadge *>()) && badge->isVisible()); + QCOMPARE(badge->findChild<QLabel *>("maskAloneTitle")->text(), QString("Layer Mask")); + QCOMPARE(badge->findChild<QLabel *>("maskAloneName")->text(), QString("Red")); + QCOMPARE(badge->height(), 26); + QCOMPARE(badge->layout()->contentsMargins(), QMargins(11, 0, 8, 0)); + QCOMPARE(badge->layout()->spacing(), 7); + // At the canvas's foot, 14 points up, in the middle. + auto *canvas = view.findChild<CanvasView *>(); + const QRect onView(badge->mapTo(&view, QPoint(0, 0)), badge->size()), canvasOnView(canvas->mapTo(&view, QPoint(0, 0)), canvas->size()); + QCOMPARE(canvasOnView.bottom() - onView.bottom(), 14); + QVERIFY(std::abs(onView.center().x() - canvasOnView.center().x()) <= 1); + // A long name ends in an ellipsis within 220 points. + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).name = QString(80, QLatin1Char('W')); }); + session.toggleMaskAlone(id); + QTRY_VERIFY((badge = view.findChild<MaskAloneBadge *>()) && badge->isVisible()); + QLabel &name = *badge->findChild<QLabel *>("maskAloneName"); + QVERIFY(name.width() <= 220 && name.text().endsWith(QChar(0x2026))); + // Its button, not the canvas under it, takes the click. + auto &close = *badge->findChild<QToolButton *>("maskAloneClose"); + QCOMPARE(close.toolTip(), QString("Show the image again (or Alt-click the mask thumbnail)")); + QCOMPARE(close.accessibleName(), QString("Stop viewing the mask")); + const QPoint middle = close.mapTo(view.window(), close.rect().center()); + QCOMPARE(view.window()->childAt(middle), &close); + QTest::mouseClick(view.window()->windowHandle(), Qt::LeftButton, Qt::NoModifier, middle); + QVERIFY(!session.maskAloneLayer() && session.isMaskSelected()); + QTRY_VERIFY(!badge->isVisible()); + // The canvas under the badge's foot still takes clicks. + session.toggleMaskAlone(id); + QTRY_VERIFY((badge = view.findChild<MaskAloneBadge *>()) && badge->isVisible()); + const QPoint below = badge->mapTo(view.window(), QPoint(badge->width() / 2, badge->height() + 7)); + QCOMPARE(view.window()->childAt(below), canvas); +} + +void MaskAloneTests::altClickOnTheThumbnailTogglesItAndOutlinesItWhite() +{ + EditorSession session; + NativeLayerList list(session); + list.resize(300, 400); + list.show(); + QVERIFY(QTest::qWaitForWindowExposed(&list)); + session.createDocument(200, 100); + const QUuid id = masked(session); + LayerCell &cell = *list.cells().at(0); + auto *thumbnail = cell.findChild<LayerThumbnailButton *>("maskThumbnail"); + QVERIFY(thumbnail); + QCOMPARE(thumbnail->toolTip(), QString("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black " + "areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); + session.selectLayerTarget(id, false); + const QPoint middle = thumbnail->rect().center(); + QTest::mouseClick(thumbnail, Qt::LeftButton, Qt::AltModifier, middle); + QCOMPARE(session.maskAloneLayer().value().id, id); + // Outlined in white rather than the accent. + const QColor ring = thumbnail->grab().toImage().pixelColor(1, middle.y()); + QCOMPARE(ring, QColor(Qt::white)); + // Released off the thumbnail, the press does nothing. + QTest::mousePress(thumbnail, Qt::LeftButton, Qt::AltModifier, middle); + QTest::mouseRelease(thumbnail, Qt::LeftButton, Qt::AltModifier, QPoint(-40, middle.y())); + QCOMPARE(session.maskAloneLayer().value().id, id); + // Shift no longer toggles the mask on an Alt-click. + QTest::mouseClick(thumbnail, Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, middle); + QVERIFY(!session.maskAloneLayer() && session.activeLayer().value().mask.value().isEnabled); + const QColor accent = thumbnail->grab().toImage().pixelColor(1, middle.y()); + QCOMPARE(accent, thumbnail->palette().color(QPalette::Highlight)); +} + +QTEST_MAIN(MaskAloneTests) +#include "MaskAloneTests.moc" diff --git a/tests/NativeLayerListTests.cpp b/tests/NativeLayerListTests.cpp index 63642b6..76b599b 100644 --- a/tests/NativeLayerListTests.cpp +++ b/tests/NativeLayerListTests.cpp @@ -469,7 +469,7 @@ void NativeLayerListTests::ctrlClicksOnThumbnailsLoadSelections() Shown shown(session); LayerCell &row = shown.row(0); QCOMPARE(row.thumbnail().toolTip(), QString("Select image pixels")); - QCOMPARE(row.maskThumbnail().toolTip(), QString("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); + QCOMPARE(row.maskThumbnail().toolTip(), QString("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); // Ctrl on the picture: its opaque pixels, the row untouched. QTest::mouseClick(&row.thumbnail(), Qt::LeftButton, Qt::ControlModifier); QCOMPARE(session.history.undoName(), QString("Load Layer Selection")); diff --git a/tests/SelectionEditTests.cpp b/tests/SelectionEditTests.cpp index 922743f..503359b 100644 --- a/tests/SelectionEditTests.cpp +++ b/tests/SelectionEditTests.cpp @@ -55,8 +55,8 @@ void invert(EditorSession &session) class SelectionEditTests : public QObject { Q_OBJECT private slots: - void maskButtonAddsWhiteMaskOrHidesTheSelection(); - void layerMenuMasksUseTheSelection(); + void maskButtonAddsWhiteMaskOrRevealsTheSelection(); + void hidingMasksUseTheSelection(); void maskFromSelectionLinesUpOnScaledLayers(); void deletingWithTheMaskTargetedRemovesOnlyTheMask(); void invertKeepsTransparencyStaysInSelectionAndWorksOnMasks(); @@ -67,7 +67,7 @@ private slots: void aPaintedAssetThatCannotFlattenReportsAndFreesTheProject(); }; -void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection() +void SelectionEditTests::maskButtonAddsWhiteMaskOrRevealsTheSelection() { const auto session = editSession(); session->insert(filledRed()); @@ -79,12 +79,12 @@ void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection() QVERIFY(!session->activeLayer().value().mask.has_value()); select(*session, QRectF(20, 10, 30, 20)); session->addMask(); - QCOMPARE(session->history.undoName(), QString("Add Mask from Selection")); + QCOMPARE(session->history.undoName(), QString("Reveal Selection")); QVERIFY(!session->selection().has_value() && session->isMaskSelected()); const QImage result = render(*session); - // The selected area is black, hidden; the rest white. - QCOMPARE(pixel(result, 30, 20)[3], 0); - QCOMPARE(pixel(result, 5, 5)[3], 255); + // The selected area is white, visible; the rest black. + QCOMPARE(pixel(result, 30, 20)[3], 255); + QCOMPARE(pixel(result, 5, 5)[3], 0); session->undo(); QVERIFY(!session->activeLayer().value().mask.has_value() && session->selection().has_value()); // A layer with a mask takes no second one. @@ -95,18 +95,18 @@ void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection() QCOMPARE(session->history.undoCount(), count + 1); } -void SelectionEditTests::layerMenuMasksUseTheSelection() +void SelectionEditTests::hidingMasksUseTheSelection() { const auto session = editSession(); session->insert(filledRed()); select(*session, QRectF(20, 10, 30, 20)); session->addMask(false); - QCOMPARE(session->history.undoName(), QString("Add Mask from Selection")); + QCOMPARE(session->history.undoName(), QString("Hide Selection")); QVERIFY(!session->selection().has_value() && session->isMaskSelected()); const QImage result = render(*session); - // The selected area is white, visible; the rest black. - QCOMPARE(pixel(result, 30, 20)[3], 255); - QCOMPARE(pixel(result, 5, 5)[3], 0); + // The selected area is black, hidden; the rest white. + QCOMPARE(pixel(result, 30, 20)[3], 0); + QCOMPARE(pixel(result, 5, 5)[3], 255); session->undo(); QVERIFY(!session->activeLayer().value().mask.has_value() && session->selection().has_value()); session->deselect(); @@ -128,7 +128,7 @@ void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers() record(snapshot, id).transform.size = QSizeF(100, 100); }); select(*session, QRectF(0, 0, 50, 50)); - session->addMask(); + session->addMask(false); // The mask uses the layer's pixel grid. QCOMPARE(session->activeLayer().value().mask.value().asset.size(), QSize(50, 50)); const QImage result = render(*session); @@ -139,7 +139,7 @@ void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers() session->undo(); session->addBlankLayer(); select(*session, QRectF(10, 10, 20, 20)); - session->addMask(); + session->addMask(false); QCOMPARE(session->activeLayer().value().mask.value().asset.size(), QSize(100, 100)); QCOMPARE(int(session->activeLayer().value().mask.value().asset.image().constScanLine(15)[15]), 0); QCOMPARE(int(session->activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); @@ -153,7 +153,7 @@ void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers() session->undo(); session->setSelectionAntialiased(soft); session->applySelection(triangle, SelectionMode::replace, "Select"); - session->addMask(); + session->addMask(false); const QImage edge = session->activeLayer().value().mask.value().asset.image(); int partial = 0; for (int x = 0; x < 100; ++x) { diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 13a922d..551fb13 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -135,6 +135,7 @@ compositor_test(LiveMaskDeleteTests) compositor_test(LiveMaskEditTests) compositor_test(LiveMaskSessionTests) compositor_test(LiveMaskTests) +compositor_test(MaskAloneTests) compositor_test(MaskPlacementTests) compositor_test(MaskSignalTests) compositor_test(MaskTracingTests) From f91849ade5f5efddf3c7c6e4028a6855310f935b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= <zhangyanghaha0407@outlook.com> Date: Thu, 1 Oct 2026 18:08:33 -0400 Subject: [PATCH 15/17] AGENTS and TASKS for 13.10, 13.11, 13.12; liveFolderMaskClip beside drawMaskAlone --- AGENTS.md | 5 +- CMakeLists.txt | 2 +- TASKS.md | 9 ++-- src/Rendering/EditorCanvas+MaskAlone.cpp | 23 --------- src/Rendering/EditorCanvas+Masks.cpp | 65 ++++++++++++++++++++++++ src/Rendering/EditorCanvas.cpp | 37 -------------- 6 files changed, 76 insertions(+), 65 deletions(-) delete mode 100644 src/Rendering/EditorCanvas+MaskAlone.cpp create mode 100644 src/Rendering/EditorCanvas+Masks.cpp diff --git a/AGENTS.md b/AGENTS.md index cdb8112..d25f4ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ Platform adaptations, each forced by a missing Qt analog: - The Move tool on the canvas (6.1): `TransformOverlay` is no widget. Swift's overlay view exists so that a selection never redraws the pixels; a transparent Qt child would repaint the canvas beneath it anyway, so the canvas draws the handles and guides itself, after its pixels, and `synchronizeDisplay` repaints only the union of the old and the new `drawnRect` (a guide spans the canvas) when the geometry or `snapGuides` changed, leaving its return value to the pixel state. `TransformOverlayGeometry` keeps Swift's handles, rotation grip and `hit`; `showsRotation` and the corner constructor arrive with 6.2. `resizeCursor` maps Swift's frame positions onto Qt's four resize shapes (right, bottom right, bottom, top right). `EditorCanvas+Transform.cpp` holds the drags (`beginTransformDrag`, `dragTransform`, `endTransformDrag`, `cancelTransformDrag`, Swift's `resignFirstResponder`), `transformPressLayer` and `transformCursor`; Ctrl stands for Cmd in picking, Ctrl+Shift extends the selection. The drag's cursor is kept in `m_dragCursor` until the release and outranks Space, where Swift disables the window's cursor rects; hover reaches `updateCursor` through mouse tracking and the last hover point, the application filter reads Alt and Ctrl as it read Alt, and a shown canvas reads both from the keyboard. The release synchronizes the display, so a Ctrl+T edit's guides go without a commit. `moveCursor` (the arrow with Swift's four-way badge) and `rotationCursor` (two arrows round a ring, for the SF symbol) are drawn as paths; the distort cursor arrives with 6.2. `SnapGuides` is a struct where Swift has a tuple, a public member like `viewport`, unannounced; `commitTransform` and `cancelTransform` clear it. `transformSnapTargets` and `snappedMove` live in `Document/Crop.cpp`, Swift's file. `TransformEdit.persistent` sits last, so the positional initialisers keep their order; a drag's edit and a duplicate's are not persistent. `transformAutoSelect`, `showsTransformControls` and `locksTransformRatio` are getters and setters that notify. Escape and Return end a drag with its edit and put the hover cursor back. A press that never moves commits an unchanged edit, which the history drops. The pixel tests hide the controls (`CanvasDisplayTests`, `HighDpiCanvasTests`) and preview through a drag's edit, since Swift's overlay never reaches its `draw` tests. - Distort (6.2): `DistortWarp` keeps Swift's tests of usability and convexity, `homography` (a projective `QTransform` where Swift returns a closure, Qt dividing by the third row as Swift by `w`), `warp`, `warpTrimmed`, `carried` and `warpMask`. Core Image's perspective filter is `QTransform::quadToQuad` on the image's pixel rect and a `drawImage` under it, antialiased and smooth; a folded shape is two triangles under their affine maps, each a hard device-pixel region of the pixels whose centres the triangle holds, built per scanline from each edge's ends ordered by y, the left bound inclusive, so the halves partition the diagonal and meet a fractional shared edge with one x (a path clip turns soft once the image draws antialiased, and `QRegion`'s integer polygons round the vertices), copied through `BrushRaster::draw` without interpolation, as Swift's. In `distortPreview` a mask warp that throws is caught on its own, as Swift's `try?`: a linked mask covering its layer then draws none, a placed one falls back, with a folded shape, to its stored placement clipped over the warp. Its `Warped` and `Trimmed` structs stand for Swift's tuples; `mapPath` arrived with 8.2b and `distortedEffects` arrived with 9.6c. Swift's `DistortTests` agree since 1.2.1 (`7111885`): a bow tie is usable and folds as two triangles, not convex, and a collapsed corner is refused by both tests; the preview takes a folded shape. `TransformEdit.corners` sits last, after `persistent`. The session's `distortPreview` and `maskDistortPreview` are `const` with mutable caches (`DistortPreviewCache` by layer, `MaskDistortPreviewCache`), keyed by `ImageIdentity` where Swift compares `CGImage` identity, because the canvas reads them from a const session; a warp that throws leaves a cached empty result, as Swift's `try?`. `commitDistort` catches `ProjectError` and `ExportError` into `brushError` as Swift catches its errors; `commitTransform` runs it after the mask path, before the plain one, and ends a duplicate after it. `commitMaskTransform` takes the corners path with `Distort Layer Mask`, `displayedMaskPlacement` its corners cases, `nudgeLayer` moves the corners with the draft. The overlay's corners constructor sets `showsRotation` false, `hit` skips the grip then, and the drawing leaves the stem and disc out; `geometry` returns the corners' geometry for a group or the edited layer. The canvas's `DisplayState` carries the edit's corners, so a moved corner repaints; `drawOwn` draws `distortPreview` first and takes `maskDistortPreview` for the mask. A Ctrl-drag on a handle begins the distortion and drags in `distort` mode with the white `distortCursor`; the cursor shows it over a handle under Ctrl or once distorted. The inspector's numbers rest while corners are set, as Swift's `.disabled`. - `TransformInspector` is the Move tool's `ToolHeaderBar`: Auto Select and Show Controls as `QCheckBox`es, the numbers in a `QScrollArea` without bars (Swift's horizontal `ScrollView`, which takes the row's spare room and keeps its content at its own size: the row's end stretch rests, the content ends in a stretch of its own), the ratio lock a checkable `QToolButton` with the panel's link icon, the sampling a `QComboBox`, Flip H and V and Cancel and Apply as `QPushButton`s. `TransformValueField` applies as typed (Swift's `isFinite` test is left out: `previewTransform` refuses every transform an infinite number makes), shows the session's number while unfocused and puts it back on `FocusOut`, steps with Up and Down (Shift ten) writing the number it applied, keeps its typing while a menu or popup borrows the focus (`MenuBarFocusReason`, `PopupFocusReason`), and on Return or Escape hands the canvas the keys as Swift's `onSubmit` and `onExitCommand`; the pending edit waits there for the canvas's Return or Escape. From any other control of the bar, Return applies and Escape cancels, Swift's default and cancel actions. The lock's icon is redrawn on `PaletteChange`, as the panel's. Swift's `.id(activeLayerID)` remake is `synchronize` clearing any focus of its own when the active layer changes. `resizeBox` is Swift's `resize`, whose name would hide `QWidget::resize`; its `number >= 1` guard and the scale's `number > 0` are left out: a side under a pixel is a box `previewTransform` refuses. The Layer menu's Transform Layer entry calls `beginTransform` (Swift's `transformCommand` does the same without a selection; 7.4 brings the selection) and the View menu's Show Transform Controls is checkable under Ctrl+H, enabled in the Move tool with a document. -- Selections (7.1): `DocumentSelection` holds a `QPainterPath` where Swift holds a `CGPath`, `SelectionClip` a `QImage` coverage; `SelectionClip::apply` has no twin, because QPainter has no soft clip: `PixelAdjust::coverage` draws the clip's coverage through the layer's inverse `pixelToDocument` into a mask surface (smooth, as CoreGraphics filters a mask clip) and `PixelAdjust::blend` mixes two images through it byte for byte, rounded, where Swift runs `CIBlendWithMask`. Qt's `united`, `subtracted` and `intersected` honour each path's own fill rule and return odd-even paths whose fill is the region they name (probed, `QPathClipper`), so an outline made here is `WindingFill` (a lasso crossing itself fills like Swift's `.winding`, a traced mask keeps its holes) and a result keeps the rule Qt gave it; every fill and `contains` reads the path's own rule, and `QTransform::map` keeps it. Expand and Contract stroke the outline with `QPainterPathStroker` (round cap and join, Swift's `copy(strokingWithWidth:)`; Swift's miter limit is idle under a round join and left out), and a contraction past the middle leaves Qt's empty path, `isEmpty`. `DragBox::rect` rounds with `std::round`, Swift's `rounded()`. The session's selection state is private with getters and setters (`lassoKind`, `marqueeKind`, `selectionModeChoice`, `selectionAntialiased`, the two amounts), `lassoDraft` and `heldSelectionMode` getters alone; `selectTool` cancels a lasso, as Swift's does. `pressMarqueeKey` and `pressLassoKey` arrive with the canvas keys of 7.2; `displayedSelection` with the ants. `PixelInvert::run` is a byte loop where Swift calls vImage (alpha − colour keeps premultiplied transparency), reading any other format as premultiplied first; `invertPixels` takes a completion callback, runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, and writes back only when the layer still holds the pixels it computed from (identities compared, Swift's `===`); the worker catches `ExportError` alone, the UI side `std::runtime_error` (the clip, the expanded 1×1 mask, the thumbnail), each a warning on `omaphoto.app` and `brushError`. `invertPixels` moves `brushRevision` after its step, as Swift's does. `addMask` from a selection draws the outline through the inverse of `pixelToDocument` into the layer's own grid; a blank layer's grid is its rounded size, as Swift's. `deleteLayerOrMask` is the session function the panel's trash, the Layer menu's Delete and (from 8.x) the Delete key share. The loaders in `MaskTracing.cpp` warn on `omaphoto.app` where Swift beeps, and fetch the pixels inside a try: a painted asset flattens on first use, and a flatten that cannot allocate loads nothing, where Swift's `CGImage` already exists. The Select menu and the Image menu's Invert sit between View and Layer, as macOS places `CommandMenu`s; Select All in a text field is the field's, like Undo (`QLineEdit` takes Ctrl+A itself through `ShortcutOverride`, so the entry's own branch matters only when triggered); Swift agrees since 1.2.0 (`90ee29f`, the responder chain first), and the entry never rests, as Swift's: without a canvas or a field it does nothing. The focus takes it first: a text field, open text, then a focused layer list, which selects every row with the top one primary, as `NSTableView`'s `selectAll(_:)` does in Swift's chain (`NativeLayerList::selectAllRows`), and only then the canvas. The panel's Ctrl-click on a thumbnail loads it (Ctrl-Shift adds, Ctrl-Alt subtracts, as Swift's `loadMode`), the row untouched; with Ctrl held over an enabled thumbnail the list shows `CanvasView::loadSelectionCursor`, a pointing hand drawn as paths with Swift's dashed badge, since `QCursor::pixmap` is empty for a shape cursor. `LayerMaskMenu` calls `addMask` and says in its tooltip what the selection becomes. +- Selections (7.1): `DocumentSelection` holds a `QPainterPath` where Swift holds a `CGPath`, `SelectionClip` a `QImage` coverage; `SelectionClip::apply` has no twin, because QPainter has no soft clip: `PixelAdjust::coverage` draws the clip's coverage through the layer's inverse `pixelToDocument` into a mask surface (smooth, as CoreGraphics filters a mask clip) and `PixelAdjust::blend` mixes two images through it byte for byte, rounded, where Swift runs `CIBlendWithMask`. Qt's `united`, `subtracted` and `intersected` honour each path's own fill rule and return odd-even paths whose fill is the region they name (probed, `QPathClipper`), so an outline made here is `WindingFill` (a lasso crossing itself fills like Swift's `.winding`, a traced mask keeps its holes) and a result keeps the rule Qt gave it; every fill and `contains` reads the path's own rule, and `QTransform::map` keeps it. Expand and Contract stroke the outline with `QPainterPathStroker` (round cap and join, Swift's `copy(strokingWithWidth:)`; Swift's miter limit is idle under a round join and left out), and a contraction past the middle leaves Qt's empty path, `isEmpty`. `DragBox::rect` rounds with `std::round`, Swift's `rounded()`. The session's selection state is private with getters and setters (`lassoKind`, `marqueeKind`, `selectionModeChoice`, `selectionAntialiased`, the two amounts), `lassoDraft` and `heldSelectionMode` getters alone; `selectTool` cancels a lasso, as Swift's does. `pressMarqueeKey` and `pressLassoKey` arrive with the canvas keys of 7.2; `displayedSelection` with the ants. `PixelInvert::run` is a byte loop where Swift calls vImage (alpha − colour keeps premultiplied transparency), reading any other format as premultiplied first; `invertPixels` takes a completion callback, runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, and writes back only when the layer still holds the pixels it computed from (identities compared, Swift's `===`); the worker catches `ExportError` alone, the UI side `std::runtime_error` (the clip, the expanded 1×1 mask, the thumbnail), each a warning on `omaphoto.app` and `brushError`. `invertPixels` moves `brushRevision` after its step, as Swift's does. `addMask` from a selection draws the outline through the inverse of `pixelToDocument` into the layer's own grid; a blank layer's grid is its rounded size, as Swift's. `deleteLayerOrMask` is the session function the panel's trash, the Layer menu's Delete and (from 8.x) the Delete key share. The loaders in `MaskTracing.cpp` warn on `omaphoto.app` where Swift beeps, and fetch the pixels inside a try: a painted asset flattens on first use, and a flatten that cannot allocate loads nothing, where Swift's `CGImage` already exists. The Select menu and the Image menu's Invert sit between View and Layer, as macOS places `CommandMenu`s; Select All in a text field is the field's, like Undo (`QLineEdit` takes Ctrl+A itself through `ShortcutOverride`, so the entry's own branch matters only when triggered); Swift agrees since 1.2.0 (`90ee29f`, the responder chain first), and the entry never rests, as Swift's: without a canvas or a field it does nothing. The focus takes it first: a text field, open text, and then the canvas, a focused layer list included: Swift's table hands Select All to the canvas since 1.4 (`451281e`), so the list's own Select All of every row (12.5b) is gone. The panel's Ctrl-click on a thumbnail loads it (Ctrl-Shift adds, Ctrl-Alt subtracts, as Swift's `loadMode`), the row untouched; with Ctrl held over an enabled thumbnail the list shows `CanvasView::loadSelectionCursor`, a pointing hand drawn as paths with Swift's dashed badge, since `QCursor::pixmap` is empty for a shape cursor. `LayerMaskMenu` calls `addMask` and says in its tooltip what the selection becomes. - The Marquee and the Lasso on the canvas (7.2): Swift's lasso gestures live in `EditorCanvas+Selection.cpp`, the selection cursors in `EditorCanvas+Cursors.cpp`. The canvas tracks Shift as it tracks Alt and Ctrl (the application filter sees the key from any window, as Swift's `flagsChanged` monitor), and a key change reshapes a Marquee draft and the cursor's badge at once. A press with a selection tool is `lassoMouseDown`; a move is `moveSelectionTool` (a hover moves a polygonal draft's rubber band and refreshes the held keys, as Swift's `mouseMoved`); a release is `releaseSelectionTool`; the draft's keys and the outline's nudges are `selectionKey`, ahead of every other key; losing focus is `endSelectionGestures`. A double click closes a polygonal outline from `mouseDoubleClickEvent`, since Qt delivers the second press there (its first press adds the corner, as Swift's first `mouseDown` does), never while Space pans. A held M or L's repeats are swallowed, as Swift ignores them, so a tool chosen meanwhile stays. An outline drag keeps `moveSelectionCursor` under Space, as the transform drag keeps its own. The ants are a 120 ms `QTimer` stepping `TransformOverlay::antsPhase`, repainting `selectionRect` alone (the outline's own rect, not the handles beside it); `drawnRect` spans the handles, the outline and the draft, the last two stroked antialiased with butt caps (`Qt::FlatCap`, as CoreGraphics dashes: Qt's square caps would stretch each dash by the pen's width) as CoreGraphics strokes them (a 1-point line on whole coordinates blurs over two rows at 1×, on macOS too); `synchronizeDisplay` repaints the overlay when the displayed selection or draft changes, refreshes the overlay's cached rect with every full repaint (a pan moves the outline) and refreshes the cursor when a kind or the displayed mode changes. The two timers are named children of the canvas (`antsTimer`, `marqueeAutoscroll`), as the session's busy timer is, so tests read whether they run. Autoscroll is a 16 ms `QTimer` with Swift's speed curve; each step translates the viewport, drags the draft or the outline again under the still pointer, notifies and synchronizes. `moveSelectionCursor`, `selectionCursor` and `wandCursor` are drawn as paths: the crosshair, the icon haloed by eight white offsets and the badge, and the wand with Swift's own strokes (hot spot 7, 7 at the sparkle, the stick down right, the badge at 17, 5), since `QCursor::pixmap` is empty for a shape cursor; the icons are `Rendering/SelectionIcons`, which the rail's `ToolIcons` and the cursors share, so nothing in `Rendering/` includes `UI/`. `SelectionIcon` is that header's enum, not nested in `CanvasView`. Ctrl over the selection shows the move pointer until 7.4 brings the scissors; the wand's click and cursor are 7.3's. `pressMarqueeKey` and `pressLassoKey` choose their tools and nothing more, as Swift's functions do: Swift's tests once expected a second press to switch the kind, which no Swift code does (its `cycleToolMode` on Tab does, 8.x); since 1.2.0 (`b5cb4fa`) they test the code's rule, as the twin does (`…AndKeepsTheShapeLastSet`), and the twin's tooltips name no key. `LassoControls` is the selection tools' `ToolHeaderBar`: Swift's segmented pickers are checkable `QToolButton`s in a `QButtonGroup`, the amounts are `SelectionAmountField`s (a `QLineEdit` taking digits alone, every typed value clamped to 1–500 as Swift's binding clamps it, stepped by arrows within the bounds, Return and Escape handing the canvas the keys, a menu's borrowed focus keeping the typing as `TransformValueField` does), the wand's controls arrive with 7.3. `ContentView::hint` takes a `ToolIconKind` for the ellipse Marquee's and the polygonal Lasso's words, and the rail's buttons repaint with the kind. - The Magic Wand (7.3): `WandPixels.c` compiles in place beside the other kernels, and `Document/MagicWand.cpp` wraps it as Swift's `MagicWand` does: `select` draws the image into a premultiplied `RGBA8888` context (the kernel's byte order, rows top down), floors the point, clamps the tolerance and hands the mask to `wand_mask`; `outline` frees the kernel's arrays through `unique_ptr` and builds a `WindingFill` path from its loops. `MagicWandError` carries Swift's two messages (`what()`), `tooDetailed` for the kernel's −2 and `memory` for −1. The session's `magicWand` takes a completion callback and runs the job through `QtConcurrent::run` and a `QFutureWatcher` with the project busy, like the invert; the worker catches `MagicWandError` and `ExportError`, and the UI side applies the outline only while the same document is open (its id, as Swift compares), New with no match deselecting, New with a match `setSelection` without the canvas clip (Swift's shortcut), the other modes `applySelection`. `wandSample` draws the composite through `drawLiveComposite` or the active layer's own pixels through `LayerRenderer::draw` at 1:1, a folder or blank layer as clear; a context that cannot be allocated logs and refuses, as Swift's `try?` returns nil. `wandSettings` is a struct with a setter that notifies; `title` and `radius` stand for Swift's computed properties of `WandSampleSize`. The canvas hands a wand press to `magicWand` with the press's mode, and a click inside the selection to `magicWand` in New mode where another tool would deselect. `LassoControls` shows the wand's controls with the wand alone: the tolerance is a `SelectionAmountField` bounded 0–255, the sample size a `QComboBox` of Swift's titles, the source two checkable buttons, Contiguous a `QCheckBox`; `changeWand` rewrites one field of the settings. The mutant that pins `m_thisLayer` checked is equivalent, since the exclusive group's later `m_allLayers` line decides, as with the marquee's kinds. - The clipboard (7.4): `SelectionClipboard.cpp` twins Swift's extension as far as the raster edits exist. `PixelClipboard` and `CopiedPixels` (Swift's tuple) live in `Document/SelectionClipboard.h`. `renderSelectedPixels` and `renderMergedPixels` draw into the copy region and multiply the selection's coverage in with `DestinationIn` through `BrushRaster::alphaView` (`clipTo`), where Swift clips the context (`SelectionClip::apply` has no twin); a mask copies as opaque gray with `LayerMask::background` for its ground when placed apart and `LayerRenderer::drawCoverage` for its white, each pass multiplied on its own (the white in a second surface), because CoreGraphics clips both and a soft edge then stacks: half coverage under a white mask is alpha 192, not 128. `store` gives the system clipboard the image and a PNG through `QMimeData`, and keeps a `QPointer` to the `QMimeData` it hands the clipboard where Swift reads the pasteboard's change count (since 12.10b; Qt's `dataChanged` is no count, since Wayland announces one copy twice): while this app owns the clipboard Qt returns that very object (offscreen, X11's client clipboard, Wayland's selection source), any other copy, a history manager's restore of the same bytes included, is another object, and a replaced object is deleted, which nulls the pointer, so a copy once replaced never counts again, as a change count never returns; a paste puts our pixels back in place while the clipboard still holds that object (`clipboardHolds`), else takes another app's image through `QMimeData::imageData`, converted to sRGB first when it carries another colour space (CoreGraphics converts as it draws; `QPainter` copies the numbers), then to the working format, tagged sRGB and centred. A cleared clipboard has no `QMimeData` at all, so `canPaste` checks the pointer. A working copy that cannot be allocated is a warning, as Swift's `try?` beeps there, where a failed Layer via Copy sets `brushError` as Swift's `catch` does. The clipboard is touched only by a copy or a paste, so session tests without one run under `QTEST_GUILESS_MAIN` still; `SelectionClipboardTests` runs under `QTEST_MAIN`. `addPixelLayer`'s shape and text arrived with 8.8 and 8.9a. Swift's `cutSelection`, `PixelMove` and the whole `FloatingSelection` extension need `clearSelectedPixels` and the brush raster, so the cut arrived with 8.2 and the rest with 8.2b, where `canEditLayers` and `ProjectWorkspace::canSwitch` gained their `pixelMove` terms. The Edit menu's Copy and Paste check when chosen, as Swift's (a text field keeps its own, as with Select All); Copy Merged follows `canCopyMerged`; the Layer menu's Ctrl+J is `layerViaCopy`, titled by the selection and enabled as Swift's: `canCopyPixels`, or no selection on an editable layer (a folder too since 12.10b). Swift's beeps are warnings on `omaphoto.app`. @@ -170,6 +170,9 @@ Platform adaptations, each forced by a missing Qt analog: - Compositor 1.4, part one (13.8: `8c0417b`, `fe7a83d`, `af3b568`, `3ccc125`, `d0135c8`, `dcf1c66`). A sharper canvas: `LayerRenderer::interpolation` takes Swift's `upright` (a layer whose `radians()` is 0, drawn within a thousandth of pixel for pixel, takes no filter), from `LayerRenderer::draw` and `TiledLayerRenderer`'s frames alike. The twin's adjustment surfaces already held device pixels (9.5b) and its adjustments already ran at the surface's scale (12.8c), so 1.4's surface and `adjustmentScale` changes were held; what was missing is ported: `AdjustmentSurface::draw` falls back to a pixel a painter unit when device pixels pass the budget (Swift's "falls back to points"), drawn back smoothly (Swift's `.high`), and nothing only when that passes too; `LayerAdjustment::apply` counts the noise origin per axis (`top × height / max(1, region height)`), where the twin used the width's scale for both. PSD (`IO/PSD`): Levels gamma in hundredths; `hue2` read as Swift's: 16 bytes at least, Colorize's own values or the Master's, then for each of the six ranges its band (degrees wrapped into 0–360) and values, 14 bytes each, until the data runs out; the reader keeps each mask's bounds and default byte (`PSDRecord::maskBounds`, `maskDefault`), and `PSDDocumentBuilder::maskOnLayerGrid` draws the stored patch, unfiltered, onto the layer's grid (its pixels, or the canvas for a layer without) at its place, the default round it; already the grid, or with no size, the patch is kept; a grid that cannot be allocated logs on `omaphoto.io` and takes the builder's existing "couldn't be converted" note, as Swift's nil does. `PSDAdjustments::levels` and `hue` are public, as Swift made them for its tests. Photoshop's Saturation: `HueSaturationFilter::adjustedSaturation`, Swift's to the letter. All cores: `kernels/LevelsPixels.c/.h` are Swift's file again (`cube_apply`); `BrushRaster::copy` (bytewise for the context's own format, drawn otherwise) and `BrushRaster::inBands` (one band under 250,000 pixels, else twice the ideal thread count, through `PoolMap`; a `bad_alloc` there is `ExportError(render)`, since the workers' callers catch that alone); `LevelsFilter::run` copies and runs `levels_apply` in bands; `HueSaturationFilter::run` copies and runs `cube_apply` in bands, where the twin had its own trilinear loop (the kernel's float order now: within a level of it, Swift's tests allow eight); `cube` keeps the last eight tables under a mutex, Swift's `cubes`. A blur's preview kept while the layer grows: `FilterEdit` gains `preparedTransform` and `pendingTransform`; a render carries the grown layer it was made from (`Filtered::placement`) and lands there, shown until the next replaces it; `displayedTransform` and `displayedMaskPlacement` read `preparedTransform`. Left out as dead in the twin: Swift's `current` terms (only a blur grows its grid, and only the automatic kinds, which never grow, refuse a stale preview or read `preparedSettings`), so the grid's version (`previewSourceVersion`, `Filtered::sourceVersion`) goes with nothing left to read it, and so does the twin's own remaking of a waiting job on growth (with Preview off nothing lands; with it on, the update makes a new job). The hierarchy kept: `LayerOrder` (`Document/LayerGroups.h`, its code in `LayerGroups+Order.cpp` with the document's hierarchy members, since `LayerGroups.cpp` would pass 500 lines): `Node`, `Result` (`order`, `visible`, `drawn`) and `resolve`, which keeps the last nodes and answer under a mutex; `CanvasDocument::hierarchy()` replaces `hierarchyEntries()`; `effectiveVisibleIDs`, `renderLayers` (a checked `std::map` index, after Swift's empty-order guard) and `effectiveOpacities` (a map of opacity and folder, no whole layers) read it; `groupSelectedLayers` reads `hierarchy().order`. `dcf1c66` was held: the canvas's `DisplayState` already works out the visible set once. Tests: `SharpCanvasTests` (the rule's edges; plain, masked and tiled layers crisp within the tolerance, filtered past it), `PSDAdjustmentTests` (Swift's three, plus the wrap, Colorize leaving the ranges, a cut record, the builder, a pixel layer's grid, a stretched layer, the patch kept, bounds or a layer of no height or width, the grid's bounds with a smaller patch, a patch the grid's size a pixel along), `PSDReaderTests` (the new layouts, the mask's bounds and default read and placed), `HueSaturationTests::positiveSaturationMatchesPhotoshop` (Swift's values, the clamps, a pixel), `AdjustmentBandsTests` (bands cover each pixel once, the copy's bytes, Levels and Hue/Saturation in bands against one pass of the kernels, cached tables), `HueSaturationFailureTests` (the filter now fails at its copy, the row list being gone), `FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne` (Swift's) and `aMaskKeepsItsPlaceUnderAPreview` (a covering mask under a preview that did not grow, and on a grown one the old bounds), `FilterSessionTests` and `FinishingFilterTests` (an older grid's preview shows on that grid: `aPreviewForAnOlderGridShowsOnThatGrid`, `aJobWaitingBehindAnotherEditShowsOnItsGrid`), `LayerOrderTests` (order, visibility and drawing against `LayerHierarchy::entries`, each shaping change seen, 64 deep, nothing shown draws nothing, opacities up known folders alone and a repeated id refused), `GroupTests`, `BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions` (each axis by its own scale, a region under a unit), `AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn` (the fallback, enlarged smoothly; a quarter of a pixel makes one). Equivalent mutants: `adjustedSaturation`'s clamps of the amount to ±1, its `max(0, …)` below zero and `share > 0` for `>= 0` (each branch gives the same number there; Swift's to the letter); the cube cache's insert, its removal of an older entry for the same settings and its trim to eight (each only spares work or memory); `BrushRaster::copy`'s bytewise branch taken never (drawing copies the same bytes), `inBands`' threshold moved by one and its `start < count` guard (Swift's; no start reaches the count unless the thread count passes some 500); `LevelsFilter::run` drawing its copy (the same bytes); `LayerOrder::resolve` keeping nothing (the cache only spares work). Untested failure path: `maskOnLayerGrid` out of memory (only an allocation that fails reaches it). Judgments: the adjustment surfaces' device resolution and `adjustmentScale` were held (9.5b, 12.8c); `dcf1c66` was held (the canvas already works out the visible set once); the rest ported. Mutants: 60 run, every one caught or recorded above (two unbuildable forms rewritten and caught). - Blending (13.9a: `6dc2219`, `8ab32c9`, `5a8f6ce`, `e6c964b`). Held, proven: clipping stacks and live-mask coverage at device resolution (the twin's `LiveMaskRenderer` has no `bounds`: its group, alpha and coverage surfaces are the painter's device since 4.x, so Swift's `resolution` and `pixelSize` have no twin); an adjustment in any mode, over the document and inside a stack, blended at full coverage, its alpha restored, then mixed through mask and opacity (`adjust`'s `blended` goes through `LayerRenderer::composite`, which hand-blends every mode QPainter lacks through `HslBlend`, so Swift's `SeparableBlend.blend` has no twin); a clipping stack composited in its base's mode in every mode (`drawComposite` hands `m_stackModes` to `LayerRenderer::composite`, the same path), on the canvas, in export and in Copy Merged alike. Ported: Soft Light by Photoshop's formula, as Core Image computes it (2bs + b²(1 − 2s) up to half, 2b(1 − s) + √b(2s − 1) above), in `SeparableBlend::channel`, and `needsSurface` names Soft Light, so `HslBlend` blends it by hand everywhere a layer, a stack or an adjustment is drawn; `LayerRenderer::compositionMode` loses its Soft Light case (dead now). Compared first: Qt's `CompositionMode_SoftLight` is the PDF's (W3C) formula, which equals Photoshop's below a source of half and above it wherever the backdrop passes 0.25; under that it takes the PDF's cubic for √b and runs up to 17 levels darker (backdrop 5/255, source 1: 19 where Photoshop gives 36). Swift's 0.9 over 0.5 gives 170 under either formula. Known difference: Swift's Core Image is "within 5 levels of Photoshop's"; the twin is Photoshop's own formula, so it may differ from Swift's by those few levels. Tests: `BlendingTests` (Swift's `clippingStacksBlendInTheirBasesMode` for Linear Dodge, Color Dodge and Multiply: Swift's numbers, Copy Merged equal to export, the canvas within a level; adjustments in Difference and Linear Light over the document and Vivid Light and Subtract inside a stack, by the numbers, at full and half opacity, in all three places; Swift's `softLightMatchesPhotoshop` plus a dark backdrop and the lower branch; Swift's `softEdgedLayersBlendTheSame` for Soft Light, Overlay, Multiply and Color Dodge: a radial soft layer over noise, Copy Merged equal to export, the canvas within a level), `BlendModeTests::everyBlendModeMatchesThePdfFormulas` (its Soft Light reference is Photoshop's now; its backdrops of 0.1 and 0.25 under sources past half catch the PDF's cubic), `ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels` (a half-unit stripe stays sharp at a device scale of two, in a stack and through coverage from a source further down). Equivalent mutant: Soft Light's `source <= 0.5` for `< 0.5` (both branches give the backdrop at a half). Judgments: stacks and coverage at device resolution, adjustments in any mode and a stack in its base's mode were held (proven by the tests above); Soft Light ported. Mutants: 5 on Soft Light, 4 caught, one equivalent (above). - The Metal canvas (13.9b: `cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`). OmaPhoto stays a CPU canvas (the port's decision): Swift's GPU canvas, its `GPUCanvas`, `GPUNoise`, `MetalWarp` and `CanvasLinesOverlay` and the GPU branches of `EditorCanvas`, have no twin; the twin's canvas is the Core Graphics canvas those commits compare against. Ported: Grain and Add Noise anchored to the document (`54dd707`): `LiveMaskRenderer::adjustmentRegion` (Swift's `adjustmentRegion`) maps what the painter shows into the document, and the canvas's `drawLayers` sets it from its centre mapping and zoom, so the smooth path counts noise in frame pixels from the document's corner and Grain in document pixels, as export does, and the crisp path's surface maps to document pixels, so a partial repaint keeps one field; the blur's scale still comes from the unmapped region. This retires 9.5b's and 12.8c's known differences (Grain and noise following the view, a partial repaint's own field); `CanvasDisplayTests::grainFollowsTheViewOnTheCanvas` is gone. Export and `drawLiveComposite` already draw in document pixels and set no region, as Swift's. Tests: `AdjustmentCanvasTests` (Swift's `matchesEveryAdjustment`: every kind through a ramp mask at 80% over a photo and a translucent layer, the canvas within a level of the export over its checkerboard; Swift's `patternsStayWithTheDocument` for Add Noise and Grain: equal to export at 100% and 200%, panned and not, and at 80% a whole-pixel pan moving the pattern with the image and a partial repaint showing the whole's pattern within two levels of resampling), `CanvasEditContractTests` (Swift's `aNewMaskRevealsTheWholeLayer`, upright and turned: a one-pixel mask on a scaled layer changes no covered pixel, the canvas as the export; `matchesWhileMovingPixels`, moving and duplicating on a masked layer at 60% under a soft selection: the preview equals the commit and the export outside the ants, the hole only for a move; `matchesWhileDraggingAGradient` in a selection at 70%: the pending gradient equals its commit outside the overlays, nothing outside the selection; `5708ed3`'s failed fill cancelling with `brushError`, no step, the document unchanged; `matchesALiveMaskOutsideAClippingStack`: a turned source with a layer between, the canvas as the export), `CanvasContractFixtures.h` (the scene, the export over the canvas's checkerboard, a gap that skips the overlays' bands, the ramp mask). `cdf8674`: rotation, flips, scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom, sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on pixel layers, filter, Levels and Hue/Saturation previews in the layer's place: held; the CPU paths are the reference and are pinned by `LayerRendererTests` (placement, sampling rules, halvings, overhang), `DownsampleTests`, `BlendModeTests`, `CanvasDisplayTests` (checkerboard, shadow and border, crisp from 200%, the rich scene against the export), `FolderOpacityTests`, `FolderMaskClipTests`, `MaskPlacementTests`, `LevelsCanvasTests`, `HueSaturationCanvasTests`, `VignetteCanvasTests`. `4b04323`: a moved or duplicated selection's preview and commit: held and now proven on a masked, translucent layer (above). Swift's laziness (`PixelMove.applyOffset`, `holed`, `liftedTarget`) only spares the GPU canvas a rebuild per move; the twin rebuilds eagerly in `movePixels`, which realises the tiles before display and commit by construction, and a failure there cancels the move with `brushError` (`PixelMoveTests::aPixelMoveHoldsTheSessionAndAFailedMoveCancels`). `5708ed3`, `91cb0b1`: live paint, erase and mask paint, paint past the old bounds under the old mask (`TiledStrokeTests`, `BrushPreviewTests`, `MaskPaintAnywhereTests`), Smudge and Liquify previews through the mask, opacity and placement (`SmearCanvasTests`, `FolderOpacityTests`): held. `GradientEdit.Fill` and `applyFill` are the GPU's laziness; the twin fills in `refreshGradient`, before display and commit by construction, a failure cancelling with `brushError` (now tested), and the commit publishes what was filled. `MetalWarpTests.matchesTheCPU`: the twin's `WarpStroke` is the CPU reference (`SmudgeLiquifyTests`); nothing to port. `93de9e7`: a one-pixel white mask revealing its layer: held (QPainter's smooth draw clamps an image's edge, as Core Graphics draws it; now tested); the live paint grid's extent and edge sampling: held (`TiledStrokeTests`, `DownsampleTests`, `TiledLayerTests`). `clampedToExtent` is GPU-only. `54dd707`: every adjustment kind with alpha, masks and opacity, blurs at the drawn scale and direction, adjustment results equal to the CPU reference, deterministic seeds: held (the canvas runs the CPU filters themselves; now tested for every kind); Grain and Add Noise anchoring: ported (above). `f773f7b`: non-adjacent live-mask sources (now tested), distortion previews (`DistortCanvasTests`, `DistortMaskTests`, `EffectsCanvasTests`), text previews keeping and handing on effects (`TypedTextCanvasTests`), drafts above the active layer (`ShapeCanvasTests`, `TypedTextCanvasTests`; inside a clipping stack the draft is drawn by `drawOwnWithDraft` inside the stack's group, as Swift's), new text when the active layer cannot draw, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`EffectsCanvasTests`, `MaskEffectsCanvasTests`, `BrushCanvasTests`, `MaskPaintAnywhereTests`): held. `distortShape`, the now-internal `imageCorners` and `editedText`/`handOffTextEffects` are GPU plumbing or a refactor of what the twin already draws. Known difference: Swift moves the pixel grid and a new text frame into an overlay view below its transform overlay, so the grid draws over the document's border hairline and the text frame under the other overlays; the twin draws the grid inside the document under the hairline and the text frame last. Both sit above the pixels and take no input; the order shows only on the hairline's edge pixels (a 13% white line), and no other overlay shows while a text frame is dragged. A port was tried and left: QPainter's antialiased hairlines make the order unprovable to a level. `4c5e1a6`, `d04f159`: texture retention, eviction and blits are GPU mechanisms; the CPU's sharp reductions are `DownsampleCache`'s, pinned by `DownsampleTests`. `b1b7490` only words a test's diagnostics. Tests tried and left: a distortion's preview against its commit differs on noise (the commit resamples the warp at its trimmed place), which Swift's tests never claim (they compare two previews); `DistortCanvasTests` pins the preview. Mutants: the region's mapping and use in `LiveMaskRenderer::adjust` and each term of the canvas's `adjustmentRegion`: 6, all caught (the width and height terms only once the partial repaint was compared). Judgments (each Swift contract, held unless named above): +- Shift on a pixel drag and Select All from the list (13.10): `CanvasView::moveSelectionTool` (`EditorCanvas+Selection.cpp`) twins Swift's `60bde4f`: with Shift held a Ctrl-drag of selected pixels zeroes the shorter axis of the offset, lengths compared (`std::abs`), a tie keeping across, as `dragSelection` already does for the outline; read from each move's own modifiers, as Swift's `event.modifierFlags`. `451281e`: Swift's table now hands Select All to the canvas (`session.selectAll()`, guarded on a document) instead of selecting every row, so the twin's `NativeLayerList::selectAllRows` (12.5b's round-3 fix) and the menu's `focusedList` branch are gone: a focused list falls through to `session().selectAll()`, which guards on the document itself. A rename's editor is a `QLineEdit`, so it keeps its own Select All first, as Swift's field editor. Tests: `SelectionCanvasTests::shiftKeepsMovedPixelsOnALine` (across, down, a tie, a long left beating a short down, without Shift, the release keeping the last offset), `SelectMenuTests::selectAllInAFieldIsTheFields` (a real Ctrl+A on the focused list selects the canvas, history "Select All", layers and the active one unchanged, the list keeping focus; Swift's `selectAllInTheLayersPanelSelectsTheCanvas`). Mutants: 5 on the Shift branch, all caught (the `abs`-less comparison caught after the left-beats-down step was added). No survivors. +- Why a brush cannot paint (13.11, `133c34a`): `EditorSession::paintRefusal` (`EditorSession+Brush.cpp`, declared in `EditorSession+Brush.h`) twins Swift's `paintRefusal`: none while `canEditLayers` refuses, without an active layer or when `canPaint` holds; then several layers, a folder's pixels, a hidden layer or one in a hidden folder, a mask turned off, an adjustment's pixels, an empty selection, in Swift's order and words, with Ctrl+D for ⌘D and the curly quotes kept. `beginBrush` splits Swift's guard: a tool that cannot paint the target (Spot Healing or Clone Stamp on a mask, a tool that is no brush) returns silently, a refused target sets `brushError` to the refusal (none when busy, so a waiting error is cleared, as Swift's `brushError = paintRefusal`). `beginWarp` sets the mask's words or the refusal. Fix beyond Swift: Swift's gradient sets the refusal only past its first guard, which is reached with a refused target only while a gradient is pending, where `canEditLayers` refuses and the refusal is always nil; so Swift's gradient says nothing on an empty selection, which its commit message says it explains. The twin's `beginGradient` sets the refusal at the first guard (no pending gradient, `canPaint` refusing); Swift's second assignment, always nil there, is left silent. `invertSelection` leaves no selection when the inverse is empty (Select All then Inverse), one `Inverse` step, as Swift's. Tests: `PaintRefusalTests` (every refusal and its order: two layers before a folder, a folder's mask turned off before the folder, an adjustment's mask before the adjustment, a layer inside a hidden folder; the gradient and the Smear; a busy session clearing, another tool silent), `SelectionTests::inverseOfEverythingDeselects` (Swift's). Equivalent mutants: `paintRefusal`'s `canPaint()` term (when `canPaint` holds every later test is false) and the empty-selection test without `isEmpty` (reached only when `canPaint` refuses for no listed reason, which needs an active layer outside `selectedLayerIDs`, which no session call makes). Painting at the layer's own resolution (13.11, `d3170ab`): `BrushStroke::Clone` is Swift's tuple `{image, placed, inGrid}`: the image painted through the tip and where it sits, in document pixels or in the stroke's own grid. `clonePixels` maps tile pixels through the grid (and `pixelToDocument` unless `inGrid`) into `placed`, scaled to the image, so a translation alone still copies or shifts by the fraction as before. `BrushStroke::gridRect` (in `BrushStroke+Commit.cpp`, beside `transform`, as Swift's) carries a document offset into the grid through the inverse map's linear part (`CGSize.applying`), turned, scaled and flipped as the layer. `cloneSample` takes the stroke and the offset: the stroke layer's own pixels placed at `gridRect(sourceRect, offset)`, or, sampling all layers, the composite at document size placed `offset` away; `blurSample` takes the stroke: the layer's pixels or mask drawn nearest at the margin of a region `ceil(3σ)` round `sourceRect`, σ the canvas's tenth of the brush (1.5 to 30) divided by the grid's document size per pixel (`sqrt|det|`) and capped at half the longer side, a mask on its edge tone first, then `PixelAdjust::gaussianBlur` (a mask's edge repeated, pixels clear past theirs) at σ × fit, where fit shrinks the sample to a budget of `min(maxSurfacePixels, max(16 M, four canvases))`; it returns the region as `placed`, `inGrid`. Swift's old mask sample (the placement's black box, `drawCoverage`) is gone with it. `beginBrush` makes the stroke first, then samples into it, as Swift's; a sample that fails returns without a stroke. The Smear's commit places its copy at document size. The brush and eraser: Swift's commit only changes its CPU fallback's stamp (`stampDiameter`), dead in the twin (8.1); the held judgment holds: the twin's coverage is rendered in the stroke's grid, so `NativeResolutionPaintTests` passes Swift's erase cases (scaled down, unequal, turned, and on the canvas scaled back up) and the blank layer's document resolution without any change to `BrushStroke::grid` or `BrushCoverage`. Tests: `NativeResolutionPaintTests` (Swift's seven, plus the offset carried through a flipped and a turned layer, pixel for pixel), `BlurToolTests` (`theSampleFollowsTheBrushSize`, `theSampleIsInTheLayersOwnPixels`: twice as fine, and a tiny layer's cap; `aHugeLayersSampleIsMadeCoarser`: 16 M and four canvases; `aMaskSampleKeepsItsEdgeTone`), the tests' document clones through `documentClone` (`BrushFixtures.h`). Cancel and Apply, and a scaled layer's row (13.11, `67cc31e`): `TransformInspector` puts Cancel and Apply in a row of their own (`pendingTransform`, spaced 12) that shows only while the edit waits (`transformEdit()->persistent`: typed values, Ctrl+T, a distortion begun so); a handle drag's edit leaves it unseen. Swift's `.opacity(0)` keeps the buttons in the layout and their keys alive, so the row keeps its place under a `QGraphicsOpacityEffect` faded over 0.12 s with CSS's ease-out (`QEasingCurve` Bézier 0, 0, 0.58, 1, SwiftUI's `.easeOut`), passes clicks through (`WA_TransparentForMouseEvents`, Swift's `allowsHitTesting(false)`) and takes no focus while unseen; Return and Escape still reach the edit through the bar, and a remapped key through `NativeShortcut` (the buttons stay `isVisible`). Known difference: Qt has no per-widget accessibility hiding short of hiding the widget, so a screen reader still finds the unseen buttons (Swift's `accessibilityHidden`). `sizeLabel(const ImageLayer &)` (declared in `NativeLayerList.h`, defined in `NativeLayerList+Cell.cpp`, Swift's extension in its UI file) gives the row "W × H px", and once the width differs from the asset's by a twentieth of a percent or more, " · " and the percent to one place at most, ties to even, in the user's locale as Swift's `.formatted`; a blank layer has no pixels to measure. Tests: `TransformInspectorTests::cancelAndApplyShowOnlyWhileAnEditWaits` (a drag's edit unseen, its click falling through and Return applying; a waiting edit fading in, clicked; Cancel), `theFieldsTakeTheSpareRoom` (the row's place), `NativeResolutionPaintTests::layersPanelSaysHowFarALayerIsScaled` (Swift's, the threshold either side, a rounded tenth, a blank layer), the row in `NativeLayerListTests`. Mutants (13.11): paint refusal 8 (2 survivors pinned by a folder's mask and an adjustment's mask, 2 equivalent as above), gradient 2, Smear 1, Inverse 1; `blurSample` 19 + 5 reruns (the unbuildable `1.0` replaced by `abs` for `sqrt`; the determinant's sign pinned by a layer turned 45°, the coarse sample's placement, size and σ × fit by an exact comparison of a 4200 × 3900 layer's sample), one survivor left untested: the `maxSurfacePixels` cap on the budget, reached only past a 50-million-pixel canvas with an 800 MB sample; `cloneSample` 4, `clonePixels` 3 (the scale pinned by `aGridCloneIsScaledOntoItsPlace`), `gridRect` 2, `beginBrush` 2 (a blank layer's Blur begins no stroke); `TransformInspector` 14, equivalent: the `pending == m_pending` guard (only restarts a fade toward the same end) and `setStartValue` (an animation without one starts from the property's value); `sizeLabel` 6 + 2 reruns, all caught. +- Masks as Photoshop's (13.12, `b3419ab`): `addMask` with a selection now reveals it (white inside, black outside, the coverage as drawn) and, `revealing` false, hides it (the coverage inverted), one `Reveal Selection` or `Hide Selection` step; the row menu's Reveal All and Hide All call it as before, so Hide All with a selection hides the selection. `LayerMaskMenu` reads Alt from `QGuiApplication::keyboardModifiers()` when clicked, Swift's `NSApp.currentEvent`, so Alt-click adds the opposite, with Swift's help in Alt's words. The session keeps `viewsMaskAlone` (a getter and a setter that notifies on a change, the badge's write; `EditorSession.h`, defined in `EditorSession.cpp` beside Swift's `didSet`) and `maskAloneLayer()` (Swift's computed property: the view asked, the mask targeted, the active layer with a mask); `isMaskSelected` gains Swift's `didSet` as a private `setIsMaskSelected` (declared in `LayerMask+Session.h`, since `EditorSession.h` sits at 499 lines; defined in `EditorSession.cpp`), which every former write of `m_isMaskSelected` now calls, so targeting the pixels, another layer, a deleted mask or a load ends the view. `maskAloneLayer` and `toggleMaskAlone` live in `LayerMask+Alone.cpp`, since `LayerMask.cpp` already held 494 lines. `toggleMaskAlone` targets the mask through `selectLayerTarget` and shows it unless it already showed, refusing where that refuses. The canvas's `DisplayState` gains `maskAlone` (the mask asset's `ImageIdentity`, whether or not enabled, since the composite may skip it), and `drawLayers` hands off to `drawMaskAlone` (`EditorCanvas+Masks.cpp`, beside `liveFolderMaskClip`, which moved there to keep `EditorCanvas.cpp` under 500 lines): the canvas filled with the mask's edge tone, then a brush stroke or gradient laid into that mask through `LayerRenderer::drawBrushPreview` (the painted mask's own raster when it has one), else the mask at its displayed placement or the layer's transform, gray where it shows; the crisp zoom path draws through `drawLayers` too. The list's mask thumbnail: Alt's release without a drag calls `toggleMaskAlone` (Shift no longer toggles there, as Swift's), the tooltip names Alt-click, and the targeted ring is white while that mask shows alone (`setTargeted`'s `alone`; Swift's `maskAloneLayer?.id == layerID` is left out as dead: the ring shows only on the active row, whose layer is the one shown alone). `LayerListFocusTests` and `LayerDragGestureTests` expected Alt-Shift to toggle the mask; they now expect Swift's view. `MaskAloneBadge` (in `UI/LayerMaskMenu`, Swift's file) is 26 points tall, 75% black with a 14% white rim, radius 13: the mask glyph at 11, "Layer Mask" 12 px semibold, the layer's name 12 px at 60% white, elided within 220 points, and an `xmark` button (a new `LayerIcon`, Swift's bold `xmark` as two round-capped strokes) with Swift's tooltip in Alt's words and accessible name, which ends the view. `ContentView` keeps one badge, shown while `maskAloneLayer` holds, in a layout nested in the canvas slot (it starts hidden through `synchronize`, which the constructor runs) (bottom, centred, 14 points up), so the room beside and below it stays the canvas's; Qt child widgets take their own clicks, so Swift's AppKit hosting has no twin. Swift's GPU fix (a mask stroke's grid starting at the mask's background) is the twin's behaviour already: `allocateTile` fills a mask tile with `maskBackground`. Tests: `MaskAloneTests` (Swift's three, plus another layer ending the view and the setter's signal, a stroke and a gradient shown as committed, a disabled and a placed mask, the crisp zoom, the badge's words, place, elision and clicks, the list's Alt-click, Shift ignored and the white ring), `SelectionEditTests` (Swift's renamed two and the scaled layer's hiding mask), `LayersPanelTests::theMaskButtonUsesTheSelection` (plain and Alt clicks with and without a selection, the tooltips, the row's Hide All). Mutants: the session 10 + 1 rerun (the active-layer term of `toggleMaskAlone` pinned by a busy refusal with another layer's mask targeted), equivalent: `maskAloneLayer`'s `m_isMaskSelected` and `layer->mask` terms (every path that clears either clears the view through `setIsMaskSelected`; Swift's guards, kept); `drawMaskAlone` 6 + 3 reruns (the placement pinned off the old box), equivalent: the stroke's `isMask` and layer terms (a stroke while a mask shows alone is always that mask's: the view needs the mask targeted, and every target switch commits a pending gradient); the canvas branch and state 2; the list 4 + 2 (a release off the thumbnail pinned); the button and badge 5 + 3 (margins and spacing pinned); `ContentView` 5 (the constructor's `hide` was dead and is gone). All caught otherwise. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index 2186788..a7454cd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -206,7 +206,7 @@ add_library(compositor_core STATIC src/Rendering/EditorCanvas+Gradient.cpp src/Rendering/EditorCanvas+Cursors.cpp src/Rendering/BrushCursorOverlay.cpp - src/Rendering/EditorCanvas+MaskAlone.cpp + src/Rendering/EditorCanvas+Masks.cpp src/Rendering/EditorCanvas+Navigation.cpp src/Rendering/EditorCanvas+Picking.cpp src/Rendering/EditorCanvas+Selection.cpp diff --git a/TASKS.md b/TASKS.md index 7ee6db5..5a63599 100644 --- a/TASKS.md +++ b/TASKS.md @@ -439,9 +439,12 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - 13.9a port (agent): Soft Light by Photoshop's formula (Qt's is the PDF's, up to 17 levels darker on dark backdrops); stacks, coverage and adjustments in every mode held and proven. Tests in `BlendingTests`, `BlendModeTests`, `ClippingStackTests`. Survivor recorded: `<= 0.5` for `< 0.5` (equivalent). No review yet. - [x] 13.9b Part three: the Metal canvas (`cdf8674`, `4b04323`, `5708ed3`, `93de9e7`, `54dd707`, `91cb0b1`, `f773f7b`, `4c5e1a6`, `d04f159`, `b1b7490`) stays CPU (the port's decision); each contract its tests hold for every canvas is judged and, where the twin lacks it, ported: Grain and Add Noise anchored to the document while panning and zooming, the canvas mapping its surface back to document coordinates (`54dd707`; the twin's adjustments take view-space regions today); every adjustment kind with alpha, own and folder masks and opacity, blurs at the drawn scale and direction (`54dd707`); a moved or duplicated selection's preview and commit (the hole cut only for a move, the rounded offset, opacity and coverage once) and the lazy tiles realised before display and commit, a failure cancelling the move with `brushError` (`4b04323`); live paint, erase and mask paint replacing patch interiors, revealing paint past the old bounds under the old mask, gradients' selection clip and opacity matching the commit, the lazy gradient fill realised before display and commit, Smudge and Liquify previews keeping mask, opacity and placement (`5708ed3`, `91cb0b1`); a new one-pixel white mask revealing its whole layer, rotated or scaled without a faded edge, and the live paint grid's extent kept (`93de9e7`); rotation, flips and scale, sharp successive reductions, premultiplied blending in every mode with layer and ancestor opacity, the checkerboard anchored to the document, the crisp composite at high zoom (`cdf8674`); non-adjacent live mask sources, distortion previews (perspective, folded, flipped, placed masks, effects), text previews keeping effects and handing them on without a flash, new drafts above the active layer inside a clipping stack, lines above the image, effects and placed masks kept while brushing, painting masks, dragging gradients and moving pixels (`f773f7b`); sharp reductions and fresh paint pixels (`4c5e1a6`, `d04f159`); `b1b7490` only words a test's diagnostics. Also named, each to judge: sampling modes and rounded-up reduction extents, premultiplied blending in sRGB, the surround, shadow and border, placed own masks and multiplied folder masks on ordinary pixel layers, and filter, Levels and Hue/Saturation previews replacing the layer's pixels with its alpha, mask, opacity and placement (`cdf8674`); image-edge sampling that carries edge values outward for filtering and keeps the image's own boundary, for ordinary images and live grids as for the white mask (`93de9e7`); adjustment results equal to the CPU reference, deterministic Noise and Grain seeds, Add Noise counted in frame pixels from the document's corner and Grain's size in document pixels (`54dd707`); a lazy gradient fill that fails cancelling the gradient with `brushError`, and a failed commit publishing it (`5708ed3`); Smudge and Liquify strokes and commits equal to the CPU algorithms (`91cb0b1`, `matchesTheCPU`: the twin's CPU path is the reference, a held judgment); new text shown when the active layer cannot draw, the pixel grid and the new text frame drawn above the image and passing input through, and edits keeping mask gray values and effects and placed masks as paint bounds grow (`f773f7b`). Texture retention, eviction and blits are GPU mechanisms with no CPU twin. - 13.9b port (agent): Grain and Add Noise anchored to the document on the canvas (`adjustmentRegion`); every other GPU contract judged held, gaps pinned in `AdjustmentCanvasTests` and `CanvasEditContractTests`; the grid over the border hairline left as a known difference. No review yet. -- [ ] 13.10 Part three: Shift keeps moved pixels on a line (`60bde4f`); Ctrl+A selects the canvas from the Layers panel (`451281e`). -- [ ] 13.11 Version 1.4.1, part one: why a brush can't paint, Select All then Inverse deselects (`133c34a`); painting at the layer's own resolution (`d3170ab`): Clone Stamp and Blur sample there, the Brush and eraser on a scaled-down, rotated or unequally scaled layer that holds pixels lay a tip of the layer's own pixels and show a smooth edge scaled back up, while an empty layer, however small its transform, paints at document resolution (`paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution`); the twin's coverage in the stroke's grid (`BrushStroke::grid`, `BrushCoverage::render`) may already hold both, a held judgment to prove with `NativeResolutionPaintTests`' cases; Cancel and Apply only when an edit waits, scaled layers' scale (`67cc31e`). -- [ ] 13.12 Part two: masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (`b3419ab`). +- [x] 13.10 Part three: Shift keeps moved pixels on a line (`60bde4f`); Ctrl+A selects the canvas from the Layers panel (`451281e`). +- 13.10 port (agent): Shift keeps a Ctrl-dragged selection's pixels on the longer axis; Ctrl+A from the Layers panel selects the canvas (`selectAllRows` gone). No review yet. +- [x] 13.11 Version 1.4.1, part one: why a brush can't paint, Select All then Inverse deselects (`133c34a`); painting at the layer's own resolution (`d3170ab`): Clone Stamp and Blur sample there, the Brush and eraser on a scaled-down, rotated or unequally scaled layer that holds pixels lay a tip of the layer's own pixels and show a smooth edge scaled back up, while an empty layer, however small its transform, paints at document resolution (`paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution`); the twin's coverage in the stroke's grid (`BrushStroke::grid`, `BrushCoverage::render`) may already hold both, a held judgment to prove with `NativeResolutionPaintTests`' cases; Cancel and Apply only when an edit waits, scaled layers' scale (`67cc31e`). +- 13.11 port (agent): `paintRefusal` says why a brush, gradient (a fix beyond Swift) or the Smear refuses; Inverse of everything deselects; Clone Stamp and Blur sample in the stroke's own grid (`Clone` as Swift's image, placed, inGrid; `gridRect`; `blurSample`'s budget); the held judgment holds (`NativeResolutionPaintTests`, no change to the grid or coverage); Cancel and Apply fade in only for a waiting edit; `sizeLabel`. No review yet. +- [x] 13.12 Part two: masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (`b3419ab`). +- 13.12 port (agent): the mask button reveals the selection, Alt-click hides it; Alt-click on a mask thumbnail shows the mask alone (`viewsMaskAlone`, `toggleMaskAlone`, `drawMaskAlone`, a white ring, `MaskAloneBadge`); `liveFolderMaskClip` moved to `EditorCanvas+Masks.cpp`. No review yet. - [ ] 13.13 Part three: Ctrl flips Auto Select both ways and Shift the aspect lock, live in the bar (`bca8f13`, `03d692c`); Move bar values apply without Apply as one step (`686d8c7`); resize handles snap (`c459f88`). - [x] 13.14 Part four: tabs drag to reorder, the ones that don't fit gather in a menu (`1faf7a0`). - 13.14 port (agent): tests in `ProjectTabLayoutTests` (Swift's six and the bounds), `ProjectTabDragTests` (drag, slide, stacking, landing, threshold, busy, lost release, a close mid-drag, the overflow pill and its menu), `ProjectWorkspaceTests` (`moveTab`) and `ProjectWorkspaceViewTests`. Survivor recorded: reporting `ended` after a click that never dragged (equivalent but after a lost release); `span`'s empty guard and `contentWidth`'s pill fallback removed as dead. No review yet. diff --git a/src/Rendering/EditorCanvas+MaskAlone.cpp b/src/Rendering/EditorCanvas+MaskAlone.cpp deleted file mode 100644 index 5b9ae63..0000000 --- a/src/Rendering/EditorCanvas+MaskAlone.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "Rendering/EditorCanvas.h" - -// Swift's drawMaskAlone: gray across the canvas, edge tone past it. -void CanvasView::drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center ¢er, QPainter &context) -{ - const LayerMask &mask = layer.mask.value(); - const int tone = qRound(LayerMask::background(mask.asset.thumbnail) * 255); - context.fillRect(QRectF(center(QPointF(0, 0)), document.size() * scale), QColor(tone, tone, tone)); - const std::optional<GradientEdit> &gradient = m_session.gradientEdit(); - const BrushStroke *stroke = m_session.brushStroke() ? m_session.brushStroke() : gradient ? gradient->raster.get() : nullptr; - // A stroke or gradient laid into it shows at once. - if (stroke && stroke->isMask && stroke->layer.id == layer.id) { - const LayerTransform &placed = stroke->paintTransform; - const std::shared_ptr<const RasterSnapshot> &raster = mask.asset.raster; - LayerRenderer::drawBrushPreview(raster ? QImage() : mask.asset.image(), placed, center(placed.center()), context, {.scale = scale}, - {.patches = stroke->patches(), .pixelWidth = stroke->width, .pixelHeight = stroke->height, - .paintingMask = false, .sourceRect = stroke->sourceRect, .raster = raster}); - return; - } - // Where a transform drag shows it, as the composite. - const LayerTransform placed = m_session.displayedMaskPlacement(layer).value_or(m_session.displayedTransform(layer)); - LayerRenderer::draw(mask.asset.image(), placed, center(placed.center()), context, {.scale = scale}); -} diff --git a/src/Rendering/EditorCanvas+Masks.cpp b/src/Rendering/EditorCanvas+Masks.cpp new file mode 100644 index 0000000..088620f --- /dev/null +++ b/src/Rendering/EditorCanvas+Masks.cpp @@ -0,0 +1,65 @@ +#include "Rendering/EditorCanvas.h" +#include "IO/ImageExporter.h" +#include "Rendering/DownsampleCache.h" +#include "Rendering/LayerRenderer.h" +#include "Rendering/RasterSnapshot.h" +#include <QPainter> + +// Swift's drawMaskAlone: gray across the canvas, edge tone past it. +void CanvasView::drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center ¢er, QPainter &context) +{ + const LayerMask &mask = layer.mask.value(); + const int tone = qRound(LayerMask::background(mask.asset.thumbnail) * 255); + context.fillRect(QRectF(center(QPointF(0, 0)), document.size() * scale), QColor(tone, tone, tone)); + const std::optional<GradientEdit> &gradient = m_session.gradientEdit(); + const BrushStroke *stroke = m_session.brushStroke() ? m_session.brushStroke() : gradient ? gradient->raster.get() : nullptr; + // A stroke or gradient laid into it shows at once. + if (stroke && stroke->isMask && stroke->layer.id == layer.id) { + const LayerTransform &placed = stroke->paintTransform; + const std::shared_ptr<const RasterSnapshot> &raster = mask.asset.raster; + LayerRenderer::drawBrushPreview(raster ? QImage() : mask.asset.image(), placed, center(placed.center()), context, {.scale = scale}, + {.patches = stroke->patches(), .pixelWidth = stroke->width, .pixelHeight = stroke->height, + .paintingMask = false, .sourceRect = stroke->sourceRect, .raster = raster}); + return; + } + // Where a transform drag shows it, as the composite. + const LayerTransform placed = m_session.displayedMaskPlacement(layer).value_or(m_session.displayedTransform(layer)); + LayerRenderer::draw(mask.asset.image(), placed, center(placed.center()), context, {.scale = scale}); +} + +// The folder's live mask, multiplied into the coverage. +FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit, double scale, const Center ¢er) const +{ + return [&edit, scale, center](const QPainter &painter, QImage &coverage) { + QImage live(coverage.size(), QImage::Format_Grayscale8); + if (live.isNull()) + throw ExportError(ExportError::Kind::render); + // Outside the mask's bounds stays hidden, as when committed. + live.fill(0); + LayerTransform transform = edit.paintTransform; + transform.sampling = LayerSampling::nearest; + const std::optional<ImportedImage> &base = edit.layer.mask ? std::optional(edit.layer.mask->asset) : std::nullopt; + { + QPainter drawing(&live); + drawing.setTransform(painter.deviceTransform()); + // Swift's displayImage: halved near the size drawn. + const double device = LayerRenderer::deviceScale(drawing); + const auto shown = [device](const QImage &image, double width) { + return DownsampleCache::shared().imageDrawnAt(image, width * device / std::max(1, image.width())); + }; + const std::shared_ptr<const RasterSnapshot> raster = base ? base->raster : nullptr; + const QImage image = base && !raster ? shown(base->image(), transform.size.width() * scale) : QImage(); + const QImage rasterBase = raster && !raster->base.isNull() + ? shown(raster->base, transform.size.width() * scale * raster->baseRect.width() / std::max(1, raster->width)) + : QImage(); + LayerRenderer::drawBrushPreview(image, transform, center(transform.center()), drawing, {.scale = scale}, + {.patches = edit.patches(), .pixelWidth = edit.width, .pixelHeight = edit.height, .paintingMask = false, + .sourceRect = edit.sourceRect, .raster = raster, .rasterBase = rasterBase}); + } + QPainter multiplying(&coverage); + if (!multiplying.isActive()) + throw ExportError(ExportError::Kind::render); + multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn); + multiplying.drawImage(QRectF(coverage.rect()), BrushRaster::alphaView(live)); + }; +} diff --git a/src/Rendering/EditorCanvas.cpp b/src/Rendering/EditorCanvas.cpp index 6d256de..a4137f8 100644 --- a/src/Rendering/EditorCanvas.cpp +++ b/src/Rendering/EditorCanvas.cpp @@ -261,43 +261,6 @@ void CanvasView::draw(QPainter &context, const QRectF &dirty) context.restore(); } -// The folder's live mask, multiplied into the coverage. -FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit, double scale, const Center ¢er) const -{ - return [&edit, scale, center](const QPainter &painter, QImage &coverage) { - QImage live(coverage.size(), QImage::Format_Grayscale8); - if (live.isNull()) - throw ExportError(ExportError::Kind::render); - // Outside the mask's bounds stays hidden, as when committed. - live.fill(0); - LayerTransform transform = edit.paintTransform; - transform.sampling = LayerSampling::nearest; - const std::optional<ImportedImage> &base = edit.layer.mask ? std::optional(edit.layer.mask->asset) : std::nullopt; - { - QPainter drawing(&live); - drawing.setTransform(painter.deviceTransform()); - // Swift's displayImage: halved near the size drawn. - const double device = LayerRenderer::deviceScale(drawing); - const auto shown = [device](const QImage &image, double width) { - return DownsampleCache::shared().imageDrawnAt(image, width * device / std::max(1, image.width())); - }; - const std::shared_ptr<const RasterSnapshot> raster = base ? base->raster : nullptr; - const QImage image = base && !raster ? shown(base->image(), transform.size.width() * scale) : QImage(); - const QImage rasterBase = raster && !raster->base.isNull() - ? shown(raster->base, transform.size.width() * scale * raster->baseRect.width() / std::max(1, raster->width)) - : QImage(); - LayerRenderer::drawBrushPreview(image, transform, center(transform.center()), drawing, {.scale = scale}, - {.patches = edit.patches(), .pixelWidth = edit.width, .pixelHeight = edit.height, .paintingMask = false, - .sourceRect = edit.sourceRect, .raster = raster, .rasterBase = rasterBase}); - } - QPainter multiplying(&coverage); - if (!multiplying.isActive()) - throw ExportError(ExportError::Kind::render); - multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn); - multiplying.drawImage(QRectF(coverage.rect()), BrushRaster::alphaView(live)); - }; -} - void CanvasView::drawLayers(const CanvasDocument &document, double scale, const Center ¢er, QPainter &context) { if (const std::optional<ImageLayer> alone = m_session.maskAloneLayer()) { From a77cce459861278a477150694f9aeaf72d01394b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= <zhangyanghaha0407@outlook.com> Date: Thu, 1 Oct 2026 18:10:13 -0400 Subject: [PATCH 16/17] Camera Raw curves as Photoshop's: the fitted parametric curve, the tone curve on each channel, one drag in the graph (13.18) --- CMakeLists.txt | 1 + kernels/AdjustPixels.c | 26 +- kernels/AdjustPixels.h | 4 +- src/Document/CameraRawColor.cpp | 45 +-- src/Document/CameraRawColor.h | 8 +- src/UI/CameraRawColorControls+Curve.cpp | 114 +++++++ src/UI/CameraRawColorControls.cpp | 117 +++---- src/UI/CameraRawColorControls.h | 16 + tests/CameraRawColorTests.cpp | 13 +- tests/CameraRawCurveGraphTests.cpp | 400 ++++++++++++++++++++++++ tests/CameraRawCurveMixerTests.cpp | 14 +- tests/CameraRawCurveTests.cpp | 213 +++++++++++++ tests/CameraRawGroupDrawTests.cpp | 12 +- tests/CameraRawGroupRowsTests.cpp | 2 +- tests/CameraRawGroupWordsTests.cpp | 14 +- tests/CameraRawWiringTests.cpp | 4 +- tests/Tests.cmake | 2 + 17 files changed, 862 insertions(+), 143 deletions(-) create mode 100644 src/UI/CameraRawColorControls+Curve.cpp create mode 100644 tests/CameraRawCurveGraphTests.cpp create mode 100644 tests/CameraRawCurveTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8983403..4ef15e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -135,6 +135,7 @@ add_library(compositor_core STATIC src/UI/TransformInspector.cpp src/UI/EffectsSheet.cpp src/UI/CameraRawColorControls.cpp + src/UI/CameraRawColorControls+Curve.cpp src/UI/CameraRawColorControls+Grading.cpp src/UI/CameraRawColorControls+Mixer.cpp src/UI/CameraRawControls.cpp diff --git a/kernels/AdjustPixels.c b/kernels/AdjustPixels.c index 6901235..c8619e1 100644 --- a/kernels/AdjustPixels.c +++ b/kernels/AdjustPixels.c @@ -729,7 +729,7 @@ static double point_weight(double h, double s, double l, const float *point) { } void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride, - const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut, + const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut, double refineSaturation, const float *mixer, int pointCount, const float *points, const float *grade, double blending, double balance, int visualize) { for (size_t y = 0; y < height; ++y) { @@ -739,16 +739,22 @@ void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, s double alpha = p[3]; if (!alpha) continue; double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); - double tone = rec709(r, g, b); - double mapped = lut_at(lumaLut, tone); - scale_luminance(&r, &g, &b, mapped); - if (refineSaturation != 0 && tone > 1e-4) { - double factor = 1 + refineSaturation * (mapped / tone - 1); - double lum = rec709(r, g, b); - r = camera_clamp(lum + (r - lum) * factor); - g = camera_clamp(lum + (g - lum) * factor); - b = camera_clamp(lum + (b - lum) * factor); + // The tone curve works on red, green and blue alike, as Photoshop's does, so contrast brings color strength + // with it. Refine Saturation below zero eases toward changing brightness alone (−100), and above zero adds + // more color. + double curvedR = lut_at(toneLut, r), curvedG = lut_at(toneLut, g), curvedB = lut_at(toneLut, b); + if (refineSaturation < 0) { + double br = r, bg = g, bb = b; + scale_luminance(&br, &bg, &bb, lut_at(toneLut, rec709(r, g, b))); + double k = -refineSaturation; + curvedR += (br - curvedR) * k; curvedG += (bg - curvedG) * k; curvedB += (bb - curvedB) * k; + } else if (refineSaturation > 0) { + double lum = rec709(curvedR, curvedG, curvedB), factor = 1 + refineSaturation; + curvedR = camera_clamp(lum + (curvedR - lum) * factor); + curvedG = camera_clamp(lum + (curvedG - lum) * factor); + curvedB = camera_clamp(lum + (curvedB - lum) * factor); } + r = curvedR; g = curvedG; b = curvedB; r = lut_at(redLut, r); g = lut_at(greenLut, g); b = lut_at(blueLut, b); double h, s, l; rgb_to_hsl(r, g, b, &h, &s, &l); diff --git a/kernels/AdjustPixels.h b/kernels/AdjustPixels.h index f171684..0232148 100644 --- a/kernels/AdjustPixels.h +++ b/kernels/AdjustPixels.h @@ -48,12 +48,12 @@ void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride // the radii match a full-size render. Grain is applied separately. Alpha is kept. // Blue over clipped shadows and red over clipped highlights, on top of the grade. Preview only. void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights); -// Curve, Color Mixer, and Color Grading after the basic grade. `lumaLut` and the channel LUTs are 256 +// Curve, Color Mixer, and Color Grading after the basic grade. `toneLut` and the channel LUTs are 256 // entries. `mixer` is 24 floats: hue, saturation, luminance for eight families, −1…1. Each point color is // 9 floats (hue, saturation, luminance, three shifts −1…1, three range half-widths). `grade` is four wheels // of hue turns, saturation 0…1, and luminance −1…1. `visualize` darkens pixels outside that point color. void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride, - const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut, + const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut, double refineSaturation, const float *mixer, int pointCount, const float *points, const float *grade, double blending, double balance, int visualize); void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_t stride, diff --git a/src/Document/CameraRawColor.cpp b/src/Document/CameraRawColor.cpp index 11495bd..7d3f418 100644 --- a/src/Document/CameraRawColor.cpp +++ b/src/Document/CameraRawColor.cpp @@ -79,25 +79,34 @@ bool CameraRawCurveSettings::adjusts() const || !isLinear(green) || !isLinear(blue); } -double CameraRawCurveSettings::parametric(double tone) const +// Bends tones below `lower` by `low`, above `upper` by `high`. +double CameraRawCurveSettings::bend(double tone, double lower, double low, double upper, double high) { - const double shadow = shadowSplit / 100, dark = darkSplit / 100, light = lightSplit / 100; - double amount, lo, hi; - if (tone < shadow) - amount = shadows, lo = 0, hi = shadow; - else if (tone < dark) - amount = darks, lo = shadow, hi = dark; - else if (tone < light) - amount = lights, lo = dark, hi = light; - else - amount = highlights, lo = light, hi = 1; - const double span = std::max(0.02, hi - lo); - const double weight = 1 - std::abs(tone - (lo + hi) / 2) / (span / 2); - // A tone in its region weighs 0 to 1, unfloored. - return std::min(1.0, std::max(0.0, tone + (amount / 100) * weight * 0.22)); + // Fitted to Photoshop: Darks −51 dips 0.1 there. + const double strength = 1.66; + if (tone < lower) + return lower * std::pow(tone / lower, std::pow(2, -low / 100 * strength)); + if (tone > upper) { + const double rest = 1 - upper; + return 1 - rest * std::pow((1 - tone) / rest, std::pow(2, high / 100 * strength)); + } + return tone; } -std::vector<float> CameraRawCurveSettings::lumaTable() const +double CameraRawCurveSettings::parametric(double tone) const +{ + if (shadows == 0 && darks == 0 && lights == 0 && highlights == 0) + return tone; + // Gamma bends, smoothed as Image › Curves smooths. + std::vector<CurvePoint> anchors; + for (int index = 0; index <= 32; ++index) { + const double x = index / 32.0; + anchors.push_back({x, bend(bend(x, shadowSplit / 100, shadows, lightSplit / 100, highlights), darkSplit / 100, darks, darkSplit / 100, lights)}); + } + return point(tone, anchors); +} + +std::vector<float> CameraRawCurveSettings::toneTable() const { std::vector<float> table(256); for (int index = 0; index < 256; ++index) @@ -262,10 +271,10 @@ void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qs const CameraRawCurveSettings shape = curve.normalized(); const CameraRawMixerSettings mixing = mixer.normalized(); const CameraRawGradingSettings grade = grading.normalized(); - const std::vector<float> luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green), + const std::vector<float> tone = shape.toneTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green), blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(), gradeFloats = grade.gradeFloats(); - adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), luma.data(), redTable.data(), greenTable.data(), + adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), tone.data(), redTable.data(), greenTable.data(), blueTable.data(), shape.refineSaturation / 100, mixerFloats.data(), int(mixing.points.size()), pointFloats.data(), gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize); } diff --git a/src/Document/CameraRawColor.h b/src/Document/CameraRawColor.h index b7e6036..ead27d1 100644 --- a/src/Document/CameraRawColor.h +++ b/src/Document/CameraRawColor.h @@ -33,15 +33,19 @@ struct CameraRawCurveSettings { static std::vector<CurvePoint> strongContrast(); static bool isLinear(const std::vector<CurvePoint> &points); bool adjusts() const; - // Lifts or lowers the region a tone belongs to. + // Photoshop's parametric curve: gamma bends, smoothed as Curves. double parametric(double tone) const; - std::vector<float> lumaTable() const; + // Applied to red, green and blue alike. + std::vector<float> toneTable() const; std::vector<float> channelTable(const std::vector<CurvePoint> &points) const; CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const; // Swift's region(for:): the parametric amount a tone moves. double ®ion(double tone); CameraRawCurveSettings normalized() const; friend bool operator==(const CameraRawCurveSettings &, const CameraRawCurveSettings &) = default; + +private: + static double bend(double tone, double lower, double low, double upper, double high); }; // One picked colour and how far its adjustment reaches. diff --git a/src/UI/CameraRawColorControls+Curve.cpp b/src/UI/CameraRawColorControls+Curve.cpp new file mode 100644 index 0000000..8052c46 --- /dev/null +++ b/src/UI/CameraRawColorControls+Curve.cpp @@ -0,0 +1,114 @@ +#include "UI/CameraRawColorControls.h" +#include <algorithm> +#include <array> +#include <cmath> + +// Swift's curve graph drags, picked once at the press. +std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::parametricDrag(QPointF at, QSizeF size) const +{ + const CameraRawCurveSettings curve = raw().curve; + const double tone = at.x() / std::max(size.width(), 1.0) * 100; + const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit}; + // Along the bottom, the nearest divider, the first of equals. + if (at.y() > size.height() - 18) { + size_t nearest = 0; + for (size_t index = 1; index < splits.size(); ++index) + if (std::abs(splits[index] - tone) < std::abs(splits[nearest] - tone)) + nearest = index; + return Drag{.kind = Drag::Kind::divider, .index = nearest, .from = at}; + } + double CameraRawCurveSettings::*key = tone < splits[0] ? &CameraRawCurveSettings::shadows + : tone < splits[1] ? &CameraRawCurveSettings::darks + : tone < splits[2] ? &CameraRawCurveSettings::lights + : &CameraRawCurveSettings::highlights; + return Drag{.kind = Drag::Kind::region, .key = key, .start = curve.*key, .from = at}; +} + +std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::pointDrag(QPointF at, QSizeF size) +{ + const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0); + std::vector<CurvePoint> points = currentPoints(); + // On a point, that point; the first of equals. + std::optional<size_t> near; + for (size_t index = 0; index < points.size(); ++index) + if (!near || std::hypot(points[index].x - x, points[index].y - y) < std::hypot(points[*near].x - x, points[*near].y - y)) + near = index; + if (near && std::hypot(points[*near].x - x, points[*near].y - y) < 0.055) { + m_selectedPoint = near; + return Drag{.kind = Drag::Kind::point, .index = *near, .from = at}; + } + // Else a new point; the ends bound its x. + if (points.size() >= 16 || !std::all_of(points.begin(), points.end(), [x](const CurvePoint &each) { return std::abs(each.x - x) > 0.01; })) + return std::nullopt; + const CurvePoint point{x, y}; + points.push_back(point); + std::stable_sort(points.begin(), points.end(), [](const CurvePoint &left, const CurvePoint &right) { return left.x < right.x; }); + store(points); + const size_t index = size_t(std::find(points.begin(), points.end(), point) - points.begin()); + m_selectedPoint = index; + return Drag{.kind = Drag::Kind::point, .index = index, .from = at}; +} + +void CameraRawCurveControls::beginDrag(QPointF at, QSizeF size) +{ + // Qt may lose a release: every press picks afresh. + const bool parametric = !m_session.filterEdit() || m_session.filterEdit()->rawPanel.curvePage == CameraRawCurvePage::parametric; + m_drag = parametric ? parametricDrag(at, size) : pointDrag(at, size); + continueDrag(at, size); +} + +void CameraRawCurveControls::continueDrag(QPointF at, QSizeF size) +{ + if (!m_drag) + return; + const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0); + const Drag drag = *m_drag; + switch (drag.kind) { + case Drag::Kind::point: { + std::vector<CurvePoint> points = currentPoints(); + if (drag.index >= points.size()) + return; + points[drag.index].y = y; + // Ends stay ends; the rest keep their order. + if (drag.index > 0 && drag.index < points.size() - 1) + points[drag.index].x = std::min(points[drag.index + 1].x - 0.01, std::max(points[drag.index - 1].x + 0.01, x)); + store(points); + return; + } + case Drag::Kind::divider: { + const double value = x * 100; + update([&drag, value](CameraRawSettings &settings) { + // Below the next divider; normalizing does the rest. + CameraRawCurveSettings &curve = settings.curve; + if (drag.index == 0) + curve.shadowSplit = std::min(value, curve.darkSplit - 2); + else if (drag.index == 1) + curve.darkSplit = std::min(curve.lightSplit - 2, value); + else + curve.lightSplit = value; + }); + return; + } + case Drag::Kind::region: { + const double amount = std::min(100.0, std::max(-100.0, drag.start - (at.y() - drag.from.y()) / std::max(size.height(), 1.0) * 200)); + update([&drag, amount](CameraRawSettings &settings) { settings.curve.*drag.key = std::round(amount); }); + return; + } + } +} + +void CameraRawCurveControls::removePoint(QPointF at, QSizeF size) +{ + std::vector<CurvePoint> points = currentPoints(); + const double x = at.x() / size.width(); + // The nearest inner point by x, within 0.04; ends stay. + std::optional<size_t> nearest; + for (size_t index = 1; index + 1 < points.size(); ++index) + if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x)) + nearest = index; + if (!nearest || !(std::abs(points[*nearest].x - x) < 0.04)) + return; + points.erase(points.begin() + std::ptrdiff_t(*nearest)); + m_selectedPoint.reset(); + store(points); +} diff --git a/src/UI/CameraRawColorControls.cpp b/src/UI/CameraRawColorControls.cpp index 2645c14..2f86a87 100644 --- a/src/UI/CameraRawColorControls.cpp +++ b/src/UI/CameraRawColorControls.cpp @@ -87,103 +87,45 @@ protected: return; } painter.setPen(Qt::NoPen); - painter.setBrush(Qt::white); - for (const CurvePoint &point : m_owner.currentPoints()) - painter.drawEllipse(QRectF(point.x * w - 4, (1 - point.y) * h - 4, 8, 8)); + const std::vector<CurvePoint> points = m_owner.currentPoints(); + for (size_t index = 0; index < points.size(); ++index) { + painter.setBrush(m_owner.m_selectedPoint == index ? palette().color(QPalette::Highlight) : QColor(Qt::white)); + painter.drawEllipse(QRectF(points[index].x * w - 4, (1 - points[index].y) * h - 4, 8, 8)); + } } - // Swift's two drags: still, a press adds; moved, it drags. + // Swift's one drag: picked at the press, kept until release. void mousePressEvent(QMouseEvent *event) override { if (event->button() == Qt::LeftButton) - m_press = event->position(); + m_owner.beginDrag(event->position(), size()); } void mouseMoveEvent(QMouseEvent *event) override { - if (!m_press || !event->buttons().testFlag(Qt::LeftButton)) - return; - if (!m_moved && QLineF(*m_press, event->position()).length() < 2) - return; - m_moved = true; - if (parametric()) - moveDivider(event->position().x()); - else - movePoint(event->position()); + if (event->buttons().testFlag(Qt::LeftButton)) + m_owner.continueDrag(event->position(), size()); } void mouseReleaseEvent(QMouseEvent *event) override { - if (event->button() == Qt::LeftButton && m_press && !m_moved && event->position() == *m_press && !parametric()) - addPoint(event->position()); - m_press.reset(); - m_moved = false; + if (event->button() == Qt::LeftButton) + m_owner.m_drag.reset(); } - // The double click's own release must add nothing back. + // The second press drags too; then the point goes. void mouseDoubleClickEvent(QMouseEvent *event) override { - if (event->button() == Qt::LeftButton && !parametric()) - removePoint(event->position()); + if (event->button() != Qt::LeftButton) + return; + m_owner.beginDrag(event->position(), size()); + if (!parametric()) + m_owner.removePoint(event->position(), size()); + m_owner.m_drag.reset(); } private: - void moveDivider(double x) - { - const double value = std::min(98.0, std::max(2.0, x / std::max(double(width()), 1.0) * 100)); - const CameraRawCurveSettings curve = m_owner.raw().curve; - const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit}; - size_t nearest = 0; - for (size_t index = 1; index < 3; ++index) - if (std::abs(splits[index] - value) < std::abs(splits[nearest] - value)) - nearest = index; - m_owner.update([nearest, value](CameraRawSettings &settings) { - (nearest == 0 ? settings.curve.shadowSplit : nearest == 1 ? settings.curve.darkSplit : settings.curve.lightSplit) = value; - }); - } - - void addPoint(QPointF at) - { - std::vector<CurvePoint> points = m_owner.currentPoints(); - points.push_back({std::min(0.99, std::max(0.01, at.x() / width())), std::min(1.0, std::max(0.0, 1 - at.y() / height()))}); - m_owner.store(points); - } - - // The nearest inner point by x; ends stay. - std::optional<size_t> nearestInner(const std::vector<CurvePoint> &points, double x) const - { - std::optional<size_t> nearest; - for (size_t index = 1; index + 1 < points.size(); ++index) - if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x)) - nearest = index; - return nearest; - } - - void movePoint(QPointF at) - { - std::vector<CurvePoint> points = m_owner.currentPoints(); - const double x = at.x() / width(); - const std::optional<size_t> index = nearestInner(points, x); - if (!index) - return; - points[*index] = {std::min(0.98, std::max(0.02, x)), std::min(1.0, std::max(0.0, 1 - at.y() / height()))}; - m_owner.store(points); - } - - void removePoint(QPointF at) - { - std::vector<CurvePoint> points = m_owner.currentPoints(); - const double x = at.x() / width(); - const std::optional<size_t> index = nearestInner(points, x); - if (!index || !(std::abs(points[*index].x - x) < 0.04)) - return; - points.erase(points.begin() + std::ptrdiff_t(*index)); - m_owner.store(points); - } - CameraRawCurveControls &m_owner; - std::optional<QPointF> m_press; - bool m_moved = false; }; namespace { @@ -250,6 +192,9 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget * presetRow->addStretch(1); points->addLayout(presetRow); connect(m_preset, &QComboBox::activated, this, [this](int preset) { + // A fix beyond Swift: a preset's points drop the choice. + if (preset != 0) + m_selectedPoint.reset(); if (preset == 1) store(CameraRawCurveSettings::linear()); else if (preset == 2) @@ -258,7 +203,7 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget * store(CameraRawCurveSettings::strongContrast()); }); m_refine = new CameraRawRow({.name = QStringLiteral("refineSaturation"), .title = QStringLiteral("Refine Saturation"), - .help = QStringLiteral("How much the RGB curve also changes color strength. Zero keeps it to brightness."), .titleWidth = 88, + .help = QStringLiteral("How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color."), .titleWidth = 88, .fixedTitle = true, .fieldWidth = 48, .scrub = 1}, [this] { return raw().curve.refineSaturation; }, [this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); }, @@ -334,15 +279,23 @@ void CameraRawCurveControls::synchronize() m_channelBox->setVisible(point); const CameraRawPointChannel channel = edit ? edit->rawPanel.pointChannel : CameraRawPointChannel::rgb; m_channel->button(int(channel))->setChecked(true); - m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click the curve to add one. Double-click a point to remove it.") - : QStringLiteral("Drag a divider to change which tones the neighboring sliders affect.")); + // Swift's onChange: another channel drops the choice and drag. + if (channel != m_shownChannel) { + m_shownChannel = channel; + m_selectedPoint.reset(); + m_drag.reset(); + } + m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click to add one. Double-click a point to remove it.") + : QStringLiteral("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers.")); m_graph->update(); m_amounts->setVisible(!point); m_points->setVisible(point); const std::vector<CurvePoint> points = currentPoints(); - // Swift's selectedPoint: the last inner point, else the last. - const CurvePoint selected = points.size() > 2 ? points[points.size() - 2] : points.back(); - m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(selected.x * 255)).arg(std::lround(selected.y * 255))); + // Swift's selectedPoint: the chosen index, while it exists. + const bool chosen = m_selectedPoint && *m_selectedPoint < points.size(); + m_selected->setVisible(chosen); + if (chosen) + m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(points[*m_selectedPoint].x * 255)).arg(std::lround(points[*m_selectedPoint].y * 255))); const QSignalBlocker quiet(m_preset); m_preset->setCurrentIndex(points == CameraRawCurveSettings::linear() ? 1 : points == CameraRawCurveSettings::mediumContrast() ? 2 diff --git a/src/UI/CameraRawColorControls.h b/src/UI/CameraRawColorControls.h index 404c4c4..2412bf7 100644 --- a/src/UI/CameraRawColorControls.h +++ b/src/UI/CameraRawColorControls.h @@ -28,9 +28,25 @@ private: void update(const std::function<void(CameraRawSettings &)> &change); void store(const std::vector<CurvePoint> &points); void panel(const std::function<void(CameraRawPanel &)> &change); + // Swift's Drag: a point, divider, or region. + struct Drag { + enum class Kind { point, divider, region } kind; + size_t index = 0; + double CameraRawCurveSettings::*key = nullptr; + double start = 0; + QPointF from; + }; + std::optional<Drag> parametricDrag(QPointF at, QSizeF size) const; + std::optional<Drag> pointDrag(QPointF at, QSizeF size); + void beginDrag(QPointF at, QSizeF size); + void continueDrag(QPointF at, QSizeF size); + void removePoint(QPointF at, QSizeF size); friend class CameraRawCurveGraph; EditorSession &m_session; + std::optional<Drag> m_drag; + std::optional<size_t> m_selectedPoint; + CameraRawPointChannel m_shownChannel = CameraRawPointChannel::rgb; QButtonGroup *m_page; QWidget *m_channelBox; QButtonGroup *m_channel; diff --git a/tests/CameraRawColorTests.cpp b/tests/CameraRawColorTests.cpp index fec4af9..b495084 100644 --- a/tests/CameraRawColorTests.cpp +++ b/tests/CameraRawColorTests.cpp @@ -29,7 +29,7 @@ class CameraRawColorTests : public QObject { Q_OBJECT private slots: void curvesAreRepairedIntoOrder(); - void theParametricCurveLiftsItsRegion(); + void aToneFindsItsRegion(); void aNudgeMovesTheFirstNearestPointWithinRange(); void splitsAndAmountsStayInTheirRanges(); void mixerFamiliesOverlapRoundTheWheel(); @@ -56,17 +56,8 @@ void CameraRawColorTests::curvesAreRepairedIntoOrder() QCOMPARE(table[100], float(100 / 255.0)); } -void CameraRawColorTests::theParametricCurveLiftsItsRegion() +void CameraRawColorTests::aToneFindsItsRegion() { - CameraRawCurveSettings curve; - curve.shadows = 100; - // The region's middle takes the whole 22%. - QVERIFY(std::abs(curve.parametric(0.125) - 0.345) < 1e-12); - QCOMPARE(curve.parametric(0.375), 0.375); - curve.highlights = -100; - curve.lightSplit = 100; - // A region of no width still takes a fiftieth's span. - QVERIFY(std::abs(curve.parametric(1) - 0.78) < 1e-12); // Region picks the amount a tone moves. CameraRawCurveSettings regions; regions.region(0.1) = 1; diff --git a/tests/CameraRawCurveGraphTests.cpp b/tests/CameraRawCurveGraphTests.cpp new file mode 100644 index 0000000..d95a673 --- /dev/null +++ b/tests/CameraRawCurveGraphTests.cpp @@ -0,0 +1,400 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "UI/CameraRawColorControls.h" +#include <QComboBox> +#include <QLabel> +#include <QToolButton> +#include <QtTest> + +// Swift 1.4.5: the Camera Raw curve graph's one drag. +namespace { +struct Graph { + EditorSession session; + std::unique_ptr<CameraRawCurveControls> controls; + QWidget *graph = nullptr; + explicit Graph(bool point = false) + { + session.createDocument(8, 8); + QImage image = BrushRaster::context(8, 8, false); + image.fill(QColor(200, 120, 60)); + session.insert(ImportedImage(image, image, QStringLiteral("Warm"))); + session.beginFilter(FilterKind::cameraRaw); + controls = std::make_unique<CameraRawCurveControls>(session); + controls->resize(374, 600); + controls->show(); + graph = controls->findChild<QWidget *>(QStringLiteral("cameraRawCurveGraph")); + if (point) + controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click(); + } + const CameraRawCurveSettings &curve() const { return session.filterEdit().value().settings.cameraRaw.curve; } + void set(const std::function<void(CameraRawCurveSettings &)> &change) + { + FilterSettings settings = session.filterEdit().value().settings; + change(settings.cameraRaw.curve); + session.updateFilter(settings, true); + } + // A curve position in the graph's own points. + QPoint at(double x, double y) const { return QPoint(int(std::lround(x * graph->width())), int(std::lround((1 - y) * graph->height()))); } + void press(QPoint where, Qt::MouseButton button = Qt::LeftButton) const { QTest::mousePress(graph, button, {}, where); } + void move(QPoint where, Qt::MouseButtons buttons = Qt::LeftButton) const + { + QMouseEvent event(QEvent::MouseMove, where, graph->mapToGlobal(where), Qt::NoButton, buttons, Qt::NoModifier); + QApplication::sendEvent(graph, &event); + } + void release(QPoint where) const { QTest::mouseRelease(graph, Qt::LeftButton, {}, where); } + QLabel &readout() const { return *controls->findChild<QLabel *>(QStringLiteral("curveSelectedPoint")); } + QColor dot(double x, double y) const { return graph->grab().toImage().pixelColor(at(x, y)); } +}; +} + +class CameraRawCurveGraphTests : public QObject { + Q_OBJECT +private slots: + void aRegionDragLiftsOrLowersThePressedRegion(); + void theRegionIsPickedOnceByItsTone(); + void dividersMoveInTheBottomStripAndStayOrdered(); + void aPointIsPickedByDistanceAndKept(); + void aPressElsewhereAddsAndDragsAPoint(); + void addingIsRefusedNearAnEdgeAPointOrSixteen(); + void theChosenPointIsLitAndReadOut(); + void aNewChannelOrPresetDropsTheChoice(); + void aDoubleClickRemovesAnInnerPointAndTheChoice(); + void onlyTheLeftButtonDragsAndAPressStartsAfresh(); + void theGraphSaysWhatADragDoes(); +}; + +void CameraRawCurveGraphTests::aRegionDragLiftsOrLowersThePressedRegion() +{ + Graph graph; + const int h = graph.graph->height(); + graph.set([](CameraRawCurveSettings &curve) { curve.lights = 10; }); + // Up lifts: a quarter height adds 50. + graph.press(QPoint(int(graph.graph->width() * 0.6), 100)); + graph.move(QPoint(int(graph.graph->width() * 0.6), 100 - h / 4)); + QCOMPARE(graph.curve().lights, std::round(10 + double(h / 4) / h * 200)); + // Down lowers, clamped at −100, rounded to whole. + graph.move(QPoint(int(graph.graph->width() * 0.6), 100 + h)); + QCOMPARE(graph.curve().lights, -100.0); + graph.move(QPoint(10, 100 + 3)); + QCOMPARE(graph.curve().lights, std::round(10 - 3.0 / h * 200)); + graph.release(QPoint(10, 103)); + QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().highlights == 0); + // Far up, +100 at most. + graph.press(QPoint(int(graph.graph->width() * 0.9), 120)); + graph.move(QPoint(int(graph.graph->width() * 0.9), 120 - 4 * h)); + QCOMPARE(graph.curve().highlights, 100.0); +} + +void CameraRawCurveGraphTests::theRegionIsPickedOnceByItsTone() +{ + Graph graph; + const int w = graph.graph->width(); + graph.set([](CameraRawCurveSettings &curve) { curve.shadowSplit = 20; }); + // A tone on a divider joins the region above. + const std::pair<double, double CameraRawCurveSettings::*> regions[] = { + {0.1, &CameraRawCurveSettings::shadows}, {0.2, &CameraRawCurveSettings::darks}, {0.5, &CameraRawCurveSettings::lights}, {0.75, &CameraRawCurveSettings::highlights}}; + for (const auto &[tone, key] : regions) { + const CameraRawCurveSettings before = graph.curve(); + graph.press(QPoint(int(std::lround(tone * w)), 60)); + graph.move(QPoint(int(std::lround(tone * w)), 50)); + graph.release(QPoint(int(std::lround(tone * w)), 50)); + CameraRawCurveSettings expected = before; + expected.*key = std::round(10.0 / graph.graph->height() * 200); + QVERIFY2(graph.curve() == expected, qPrintable(QString::number(tone))); + } + // At tone 50 exactly, each divider hands it upward. + QCOMPARE(w % 2, 0); + const std::tuple<std::array<double, 3>, double CameraRawCurveSettings::*> onDividers[] = { + {{50, 60, 80}, &CameraRawCurveSettings::darks}, {{20, 50, 80}, &CameraRawCurveSettings::lights}, {{20, 30, 50}, &CameraRawCurveSettings::highlights}}; + for (const auto &[splits, key] : onDividers) { + graph.set([&splits](CameraRawCurveSettings &curve) { + curve = CameraRawCurveSettings(); + curve.shadowSplit = splits[0]; + curve.darkSplit = splits[1]; + curve.lightSplit = splits[2]; + }); + graph.press(QPoint(w / 2, 60)); + graph.move(QPoint(w / 2, 50)); + graph.release(QPoint(w / 2, 50)); + QVERIFY(graph.curve().*key > 0); + } + // Kept through the drag: across another region, the same one. + graph.set([](CameraRawCurveSettings &curve) { curve.shadows = curve.darks = curve.lights = curve.highlights = 0; }); + graph.press(QPoint(int(0.1 * w), 60)); + graph.move(QPoint(int(0.9 * w), 40)); + QVERIFY(graph.curve().shadows > 0 && graph.curve().highlights == 0); +} + +void CameraRawCurveGraphTests::dividersMoveInTheBottomStripAndStayOrdered() +{ + Graph graph; + const int w = graph.graph->width(), h = graph.graph->height(); + // The strip is the bottom 18 points. + graph.press(QPoint(int(0.5 * w), h - 18)); + graph.move(QPoint(int(0.6 * w), h - 30)); + graph.release(QPoint(int(0.6 * w), h - 30)); + QCOMPARE(graph.curve().darkSplit, 50.0); + QVERIFY(graph.curve().lights > 0); + graph.set([](CameraRawCurveSettings &curve) { curve.lights = 0; }); + // Each divider, the nearest to the press, between its neighbours. + const auto dragged = [&](double from, double to) { + graph.press(QPoint(int(std::lround(from * w)), h - 17)); + graph.move(QPoint(int(std::lround(to * w)), 10)); + graph.release(QPoint(int(std::lround(to * w)), 10)); + }; + dragged(0.3, 0.9); + QCOMPARE(graph.curve().shadowSplit, 48.0); + // Past 2 and 98 the settings' own limits hold. + dragged(0.47, 0.0); + QCOMPARE(graph.curve().shadowSplit, 5.0); + dragged(0.55, 0.9); + QCOMPARE(graph.curve().darkSplit, 73.0); + dragged(0.73, 0.01); + QCOMPARE(graph.curve().darkSplit, 7.0); + dragged(0.74, 0.0); + QCOMPARE(graph.curve().lightSplit, 9.0); + dragged(0.095, 1.2); + QCOMPARE(graph.curve().lightSplit, 98.0); + QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().lights == 0 && graph.curve().highlights == 0); + // At tone 50 exactly, between 40 and 60: the first. + QCOMPARE(w % 2, 0); + graph.set([](CameraRawCurveSettings &curve) { + curve.shadowSplit = 40; + curve.darkSplit = 60; + curve.lightSplit = 80; + }); + graph.press(QPoint(w / 2, h - 5)); + graph.move(QPoint(int(0.45 * w), h - 5)); + QVERIFY(std::abs(graph.curve().shadowSplit - 45) < 0.5 && graph.curve().darkSplit == 60); +} + +void CameraRawCurveGraphTests::aPointIsPickedByDistanceAndKept() +{ + Graph graph(true); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.36, 0.5}, {1, 1}}; }); + // Nearer by distance, not by x: the second. + graph.press(graph.at(0.33, 0.48)); + graph.move(graph.at(0.33, 0.6)); + QCOMPARE(graph.curve().rgb.size(), size_t(4)); + QVERIFY(std::abs(graph.curve().rgb[2].y - 0.6) < 0.01 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); + // Kept as it passes x 0.99, held before the end. + graph.move(graph.at(1.2, 0.2)); + QVERIFY(std::abs(graph.curve().rgb[2].x - 0.99) < 1e-9 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); + // Onto its neighbour: a hundredth past, then repaired away. + graph.move(graph.at(0.1, 0.2)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); + graph.release(graph.at(0.1, 0.2)); + // Onto the next point: a hundredth short; that one goes. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.5, 0.5}, {1, 1}}; }); + graph.press(graph.at(0.3, 0.3)); + graph.move(graph.at(0.8, 0.3)); + graph.release(graph.at(0.8, 0.3)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.49) < 1e-9); + // 0.05 away takes the point; 0.06 away adds one. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); + graph.press(graph.at(0.53, 0.54)); + graph.release(graph.at(0.53, 0.54)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.53) < 0.003); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); + graph.press(graph.at(0.54, 0.545)); + graph.release(graph.at(0.54, 0.545)); + QCOMPARE(graph.curve().rgb.size(), size_t(4)); + QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.5, 0.5})); +} + +void CameraRawCurveGraphTests::aPressElsewhereAddsAndDragsAPoint() +{ + Graph graph(true); + graph.press(graph.at(0.25, 0.6)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.25) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.6) < 0.01); + // The same press drags it on, in order. + graph.move(graph.at(0.4, 0.7)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.7) < 0.01); + graph.release(graph.at(0.4, 0.7)); + // Added below, sorted in at its x. + graph.press(graph.at(0.8, 0.95)); + graph.move(graph.at(0.7, 1.4)); + graph.release(graph.at(0.7, 1.4)); + QCOMPARE(graph.curve().rgb.size(), size_t(4)); + QVERIFY(std::abs(graph.curve().rgb[2].x - 0.7) < 0.01 && graph.curve().rgb[2].y == 1); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01); + // The ends move their output alone. + graph.press(graph.at(1, 1)); + graph.move(graph.at(0.6, 0.8)); + graph.release(graph.at(0.6, 0.8)); + QVERIFY(graph.curve().rgb.back().x == 1 && std::abs(graph.curve().rgb.back().y - 0.8) < 0.01); + graph.press(graph.at(0, 0)); + graph.move(graph.at(0.3, -0.5)); + graph.release(graph.at(0.3, -0.5)); + QVERIFY(graph.curve().rgb.front() == (CurvePoint{0, 0})); +} + +void CameraRawCurveGraphTests::addingIsRefusedNearAnEdgeAPointOrSixteen() +{ + Graph graph(true); + // Too near an end's x, or a point's. + const int w = graph.graph->width(); + graph.press(QPoint(2, 20)); + graph.move(QPoint(40, 30)); + graph.release(QPoint(40, 30)); + graph.press(QPoint(w - 2, 140)); + graph.release(QPoint(w - 2, 140)); + QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear()); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.2}, {1, 1}}; }); + graph.press(graph.at(0.505, 0.9)); + graph.move(graph.at(0.6, 0.9)); + graph.release(graph.at(0.6, 0.9)); + QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.2}, {1, 1}})); + QVERIFY(!graph.readout().isVisible()); + // Sixteen points take no seventeenth. + graph.set([](CameraRawCurveSettings &curve) { + curve.rgb = {{0, 0}}; + for (int index = 1; index < 15; ++index) + curve.rgb.push_back({index * 0.06, index * 0.06}); + curve.rgb.push_back({1, 1}); + }); + QCOMPARE(graph.curve().rgb.size(), size_t(16)); + graph.press(graph.at(0.93, 0.2)); + graph.release(graph.at(0.93, 0.2)); + QCOMPARE(graph.curve().rgb.size(), size_t(16)); + // Fifteen take a sixteenth. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb.erase(curve.rgb.begin() + 1); }); + graph.press(graph.at(0.93, 0.2)); + graph.release(graph.at(0.93, 0.2)); + QCOMPARE(graph.curve().rgb.size(), size_t(16)); +} + +void CameraRawCurveGraphTests::theChosenPointIsLitAndReadOut() +{ + Graph graph(true); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.4}, {0.7, 0.6}, {1, 1}}; }); + const QColor accent = graph.graph->palette().color(QPalette::Highlight); + QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white)); + QVERIFY(!graph.readout().isVisible()); + const auto shown = [&graph](size_t index) { + const CurvePoint point = graph.curve().rgb[index]; + return QStringLiteral("In %1 Out %2").arg(std::lround(point.x * 255)).arg(std::lround(point.y * 255)); + }; + // The press takes the point to the pointer. + graph.press(graph.at(0.3, 0.4)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.3) < 0.003 && std::abs(graph.curve().rgb[1].y - 0.4) < 0.007); + QCOMPARE(graph.dot(0.3, 0.4), accent); + QCOMPARE(graph.dot(0.7, 0.6), QColor(Qt::white)); + QCOMPARE(graph.readout().text(), shown(1)); + // The readout follows the drag. + graph.move(graph.at(0.4, 0.5)); + QCOMPARE(graph.readout().text(), shown(1)); + QVERIFY(graph.readout().text().startsWith("In 102 Out 12")); + graph.release(graph.at(0.4, 0.5)); + // An end point is chosen too. + graph.press(graph.at(1, 1)); + graph.release(graph.at(1, 1)); + QCOMPARE(graph.readout().text(), QString("In 255 Out 255")); + QCOMPARE(graph.graph->grab().toImage().pixelColor(graph.graph->width() - 2, 2), accent); + // A new point is chosen as it is made. + graph.press(graph.at(0.85, 0.2)); + graph.release(graph.at(0.85, 0.2)); + QCOMPARE(graph.readout().text(), shown(3)); + QVERIFY(std::abs(graph.curve().rgb[3].x - 0.85) < 0.003); + QCOMPARE(graph.dot(0.85, 0.2), accent); + // A choice past the points changed elsewhere hides. + graph.press(graph.at(1, 1)); + graph.release(graph.at(1, 1)); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = CameraRawCurveSettings::linear(); }); + QVERIFY(!graph.readout().isVisible()); +} + +void CameraRawCurveGraphTests::aNewChannelOrPresetDropsTheChoice() +{ + Graph graph(true); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = curve.red = {{0, 0}, {0.3, 0.4}, {1, 1}}; }); + graph.press(graph.at(0.3, 0.4)); + QVERIFY(graph.readout().isVisible()); + // Another channel: no choice, and the held drag lets go. + graph.controls->findChild<QToolButton *>(QStringLiteral("pointChannel1"))->click(); + QVERIFY(!graph.readout().isVisible()); + QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white)); + graph.move(graph.at(0.3, 0.9)); + QVERIFY(graph.curve().red == (std::vector<CurvePoint>{{0, 0}, {0.3, 0.4}, {1, 1}})); + graph.release(graph.at(0.3, 0.9)); + // A preset's points drop it too; Custom keeps it. + graph.press(graph.at(0.3, 0.4)); + graph.release(graph.at(0.3, 0.4)); + auto &preset = *graph.controls->findChild<QComboBox *>(QStringLiteral("curvePreset")); + emit preset.activated(0); + graph.set([](CameraRawCurveSettings &curve) { curve.refineSaturation = 10; }); + QVERIFY(graph.readout().isVisible()); + emit preset.activated(2); + QVERIFY(graph.curve().red == CameraRawCurveSettings::mediumContrast()); + QVERIFY(!graph.readout().isVisible()); + QCOMPARE(graph.dot(0.25, 0.18), QColor(Qt::white)); +} + +void CameraRawCurveGraphTests::aDoubleClickRemovesAnInnerPointAndTheChoice() +{ + Graph graph(true); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); + QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.5, 0.5))); + QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear()); + QVERIFY(!graph.readout().isVisible()); + // On empty ground: one click adds, two remove. + QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.4, 0.8))); + QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear()); + // A move after it drags nothing. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.6, 0.6}, {1, 1}}; }); + QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(0.3, 0.3)); + graph.move(graph.at(0.5, 0.9)); + QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.6, 0.6}, {1, 1}})); + // An end point stays, though chosen. + QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(1, 1)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QCOMPARE(graph.readout().text(), QString("In 255 Out 255")); +} + +void CameraRawCurveGraphTests::onlyTheLeftButtonDragsAndAPressStartsAfresh() +{ + Graph graph(true); + graph.press(graph.at(0.5, 0.8), Qt::RightButton); + graph.move(graph.at(0.5, 0.2), Qt::RightButton); + QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear()); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); + QTest::mouseDClick(graph.graph, Qt::RightButton, {}, graph.at(0.5, 0.5)); + QVERIFY(graph.curve().rgb == (std::vector<CurvePoint>{{0, 0}, {0.5, 0.5}, {1, 1}})); + // A move without the left button drags nothing. + graph.press(graph.at(0.5, 0.5)); + graph.move(graph.at(0.5, 0.9), Qt::RightButton); + QVERIFY(std::abs(graph.curve().rgb[1].y - 0.5) < 0.01); + graph.release(graph.at(0.5, 0.5)); + // A lost release: the next press picks its own target. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.7, 0.7}, {1, 1}}; }); + graph.press(graph.at(0.3, 0.3)); + graph.press(graph.at(0.7, 0.7)); + graph.move(graph.at(0.7, 0.9)); + QVERIFY(std::abs(graph.curve().rgb[1].y - 0.3) < 0.01 && std::abs(graph.curve().rgb[2].y - 0.9) < 0.01); + // A release lets go: moves then change nothing. + graph.release(graph.at(0.7, 0.9)); + const std::vector<CurvePoint> held = graph.curve().rgb; + graph.move(graph.at(0.5, 0.1)); + QVERIFY(graph.curve().rgb == held); + // Parametric too: a release ends the region. + graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage0"))->click(); + graph.press(QPoint(30, 80)); + graph.release(QPoint(30, 80)); + graph.move(QPoint(30, 10)); + QCOMPARE(graph.curve().shadows, 0.0); +} + +void CameraRawCurveGraphTests::theGraphSaysWhatADragDoes() +{ + Graph graph; + QCOMPARE(graph.graph->toolTip(), QString("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers.")); + graph.controls->findChild<QToolButton *>(QStringLiteral("curvePage1"))->click(); + QCOMPARE(graph.graph->toolTip(), QString("Drag a point. Click to add one. Double-click a point to remove it.")); +} + +QTEST_MAIN(CameraRawCurveGraphTests) +#include "CameraRawCurveGraphTests.moc" diff --git a/tests/CameraRawCurveMixerTests.cpp b/tests/CameraRawCurveMixerTests.cpp index a631439..a34bb82 100644 --- a/tests/CameraRawCurveMixerTests.cpp +++ b/tests/CameraRawCurveMixerTests.cpp @@ -94,14 +94,20 @@ void CameraRawCurveMixerTests::theParametricPageMovesRegionsAndDividers() QTest::mouseDClick(&slider, Qt::LeftButton, {}, knob(slider)); QCOMPARE(group.raw().curve.*member, 0.0); } - // A drag moves the nearest divider, within 2 and 98. + // Along the bottom: the nearest divider, kept ordered. auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph")); - press(graph, QPoint(int(graph.width() * 0.3), 75)); - drag(graph, QPoint(int(graph.width() * 0.35), 75)); + const int h = graph.height(); + press(graph, QPoint(int(graph.width() * 0.3), h - 5)); + drag(graph, QPoint(int(graph.width() * 0.35), h - 5)); QVERIFY(std::abs(group.raw().curve.shadowSplit - 35) < 1 && group.raw().curve.darkSplit == 50); drag(graph, QPoint(graph.width() + 40, 75)); - QVERIFY(group.raw().curve.lightSplit == 98 || group.raw().curve.shadowSplit == 98 || group.raw().curve.darkSplit == 98); + QCOMPARE(group.raw().curve.shadowSplit, 48.0); QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(graph.width() + 40, 75)); + // Above it a drag lifts the region under the press. + press(graph, QPoint(int(graph.width() * 0.3), 75)); + drag(graph, QPoint(int(graph.width() * 0.9), 75 - h / 4)); + QCOMPARE(group.raw().curve.shadows, std::round(double(h / 4) / h * 200)); + QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(int(graph.width() * 0.9), 75 - h / 4)); // A double click here leaves the point curve's points. group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); QTest::mouseDClick(&graph, Qt::LeftButton, {}, QPoint(graph.width() / 2, graph.height() / 2)); diff --git a/tests/CameraRawCurveTests.cpp b/tests/CameraRawCurveTests.cpp new file mode 100644 index 0000000..6005ca8 --- /dev/null +++ b/tests/CameraRawCurveTests.cpp @@ -0,0 +1,213 @@ +#include "CameraRawFixtures.h" +#include <QtTest> +#include <cmath> + +// Swift 1.4.5: Camera Raw's curves as Photoshop's, the model. +using namespace CameraRawFixtures; + +namespace { +// Swift's bend, written out apart from the code. +double bent(double tone, double lower, double low, double upper, double high) +{ + if (tone < lower && lower > 0) + return lower * std::pow(tone / lower, std::pow(2.0, -low / 100 * 1.66)); + if (tone > upper && upper < 1) + return 1 - (1 - upper) * std::pow((1 - tone) / (1 - upper), std::pow(2.0, high / 100 * 1.66)); + return tone; +} + +// The kernel's table lookup: linear between entries. +double looked(const std::vector<float> &table, double value) +{ + const double at = value * 255; + const int below = int(std::floor(at)); + if (below >= 255) + return table[255]; + return table[size_t(below)] + (table[size_t(below + 1)] - table[size_t(below)]) * (at - below); +} + +double luminance(const std::array<int, 4> &pixel) +{ + return 0.2126 * pixel[0] + 0.7152 * pixel[1] + 0.0722 * pixel[2]; +} +} + +class CameraRawCurveTests : public QObject { + Q_OBJECT +private slots: + void parametricCurveIsSmooth(); + void parametricCurveMatchesPhotoshop(); + void curveDeepensColorLikePhotoshop(); + void theCurvePassesThroughEachBentAnchor(); + void eachRegionBendsOnlyItsRange(); + void theToneTableRunsThroughThePointCurve(); + void refineSaturationEasesOrStrengthens(); +}; + +void CameraRawCurveTests::parametricCurveIsSmooth() +{ + CameraRawCurveSettings curve; + QCOMPARE(curve.parametric(0.3), 0.3); + for (const std::array<double, 4> &amounts : {std::array{100.0, 0.0, 0.0, 0.0}, {0, 100, -100, 0}, {-100, 50, 100, -60}}) { + curve.shadows = amounts[0]; + curve.darks = amounts[1]; + curve.lights = amounts[2]; + curve.highlights = amounts[3]; + std::vector<double> samples; + for (int index = 0; index <= 200; ++index) + samples.push_back(curve.parametric(index / 200.0)); + QCOMPARE(samples.front(), 0.0); + QCOMPARE(samples.back(), 1.0); + for (size_t index = 1; index < samples.size(); ++index) + QVERIFY(samples[index] >= samples[index - 1] - 1e-9); + // Finer sampling halves the slope's jumps; a corner keeps them. + const auto jump = [&curve](int steps) { + std::vector<double> slopes; + double last = curve.parametric(0); + for (int index = 1; index <= steps; ++index) { + const double value = curve.parametric(double(index) / steps); + slopes.push_back((value - last) * steps); + last = value; + } + double worst = 0; + for (size_t index = 1; index < slopes.size(); ++index) + worst = std::max(worst, std::abs(slopes[index] - slopes[index - 1])); + return worst; + }; + QVERIFY(jump(400) < jump(200) * 0.7); + } + curve = CameraRawCurveSettings(); + curve.darks = 100; + const double before = curve.parametric(0.4); + curve.darkSplit = 70; + QVERIFY(curve.parametric(0.4) > before); +} + +void CameraRawCurveTests::parametricCurveMatchesPhotoshop() +{ + CameraRawCurveSettings curve; + curve.darks = -51; + curve.lights = 59; + const std::pair<double, double> photoshop[] = {{0.093, 0.011}, {0.192, 0.089}, {0.267, 0.174}, {0.367, 0.310}, {0.491, 0.498}, + {0.616, 0.698}, {0.690, 0.804}, {0.765, 0.886}, {0.840, 0.947}, {0.915, 0.982}}; + for (const auto &[tone, expected] : photoshop) + QVERIFY2(std::abs(curve.parametric(tone) - expected) < 0.035, qPrintable(QString::number(curve.parametric(tone)))); +} + +void CameraRawCurveTests::curveDeepensColorLikePhotoshop() +{ + const QImage orange = image(2, 2, 0.85, 0.35, 0.1); + CameraRawSettings settings; + settings.curve.darks = -51; + settings.curve.lights = 59; + const std::array<int, 4> curved = pixels(settings.apply(orange))[0]; + QVERIFY2(curved[0] > 230 && curved[1] < 80, qPrintable(QString("%1 %2").arg(curved[0]).arg(curved[1]))); + settings.curve.refineSaturation = -100; + const std::array<int, 4> brightness = pixels(settings.apply(orange))[0]; + QVERIFY(double(brightness[1]) / double(brightness[0]) > 0.35); +} + +void CameraRawCurveTests::theCurvePassesThroughEachBentAnchor() +{ + CameraRawCurveSettings curve; + curve.shadows = 40; + curve.darks = -51; + curve.lights = 59; + curve.highlights = -30; + curve.shadowSplit = 20; + curve.darkSplit = 45; + curve.lightSplit = 80; + // Shadows and highlights first, then darks and lights. + for (int index = 0; index <= 32; ++index) { + const double x = index / 32.0; + const double expected = bent(bent(x, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59); + QVERIFY2(std::abs(curve.parametric(x) - expected) < 1e-9, qPrintable(QString::number(x))); + } + // Between anchors the smooth curve, not a straight line. + const double middle = (bent(bent(1 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59) + bent(bent(2 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59)) / 2; + QVERIFY(std::abs(curve.parametric(1.5 / 32) - middle) > 1e-6); +} + +void CameraRawCurveTests::eachRegionBendsOnlyItsRange() +{ + // Shadows and highlights bend past their dividers alone. + CameraRawCurveSettings shadows; + shadows.shadows = 100; + QVERIFY(shadows.parametric(4 / 32.0) > 4 / 32.0 + 0.05); + QVERIFY(std::abs(shadows.parametric(0.25) - 0.25) < 1e-9); + QVERIFY(std::abs(shadows.parametric(0.5) - 0.5) < 1e-9); + CameraRawCurveSettings highlights; + highlights.highlights = -100; + QVERIFY(highlights.parametric(28 / 32.0) < 28 / 32.0 - 0.05); + QVERIFY(std::abs(highlights.parametric(0.75) - 0.75) < 1e-9); + // Darks cover all below the middle; lights all above it. + CameraRawCurveSettings darks; + darks.darks = 100; + QVERIFY(darks.parametric(4 / 32.0) > 4 / 32.0 + 0.02 && darks.parametric(12 / 32.0) > 12 / 32.0 + 0.05); + QVERIFY(std::abs(darks.parametric(0.5) - 0.5) < 1e-9 && std::abs(darks.parametric(0.75) - 0.75) < 1e-9); + CameraRawCurveSettings lights; + lights.lights = -100; + QVERIFY(lights.parametric(20 / 32.0) < 20 / 32.0 - 0.05 && lights.parametric(28 / 32.0) < 28 / 32.0 - 0.02); + QVERIFY(std::abs(lights.parametric(0.25) - 0.25) < 1e-9); + // A divider at an end bends nothing past it. + CameraRawCurveSettings ends; + ends.shadows = 100; + ends.highlights = 100; + ends.shadowSplit = 0; + ends.lightSplit = 100; + for (int index = 0; index <= 32; ++index) + QVERIFY(std::abs(ends.parametric(index / 32.0) - index / 32.0) < 1e-9); +} + +void CameraRawCurveTests::theToneTableRunsThroughThePointCurve() +{ + CameraRawCurveSettings curve; + curve.lights = 59; + curve.rgb = {{0, 0}, {0.5, 0.6}, {1, 1}}; + const std::vector<float> table = curve.toneTable(); + QCOMPARE(table.size(), size_t(256)); + CameraRawCurveSettings point; + point.rgb = curve.rgb; + // Without amounts, the point curve alone: 0.5 to 0.6. + QVERIFY(std::abs(point.toneTable()[128] - 0.6) < 0.005); + for (const int index : {0, 64, 128, 200, 255}) { + const double expected = point.toneTable()[size_t(std::lround(curve.parametric(index / 255.0) * 255))]; + QVERIFY2(std::abs(table[size_t(index)] - expected) < 0.01, qPrintable(QString::number(index))); + } + QCOMPARE(table[255], 1.0f); +} + +void CameraRawCurveTests::refineSaturationEasesOrStrengthens() +{ + const QImage orange = image(2, 2, 0.85, 0.35, 0.1); + CameraRawSettings settings; + settings.curve.darks = -51; + settings.curve.lights = 59; + // At zero each channel runs through the tone curve alone. + const std::vector<float> table = settings.curve.toneTable(); + const std::array<int, 4> source = pixels(orange)[0], curved = pixels(settings.apply(orange))[0]; + for (int channel = 0; channel < 3; ++channel) + QVERIFY2(std::abs(curved[size_t(channel)] - looked(table, source[size_t(channel)] / 255.0) * 255) <= 1, qPrintable(QString::number(channel))); + // −100: the colour scaled to the curved brightness, hue kept. + settings.curve.refineSaturation = -100; + const std::array<int, 4> brightness = pixels(settings.apply(orange))[0]; + const double mapped = looked(table, luminance(source) / 255) * 255; + QVERIFY(std::abs(luminance(brightness) - mapped) <= 1.5); + QVERIFY(std::abs(double(brightness[1]) / brightness[0] - double(source[1]) / source[0]) < 0.02); + // −50: halfway between the two. + settings.curve.refineSaturation = -50; + const std::array<int, 4> half = pixels(settings.apply(orange))[0]; + for (int channel = 0; channel < 3; ++channel) + QVERIFY(std::abs(half[size_t(channel)] - (curved[size_t(channel)] + brightness[size_t(channel)]) / 2.0) <= 1.5); + // +50: half again as far from luminance. + settings.curve.refineSaturation = 50; + const std::array<int, 4> stronger = pixels(settings.apply(orange))[0]; + const double lum = luminance(curved); + for (int channel = 0; channel < 3; ++channel) { + const double expected = std::clamp(lum + (curved[size_t(channel)] - lum) * 1.5, 0.0, 255.0); + QVERIFY2(std::abs(stronger[size_t(channel)] - expected) <= 1.5, qPrintable(QString("%1 %2").arg(stronger[size_t(channel)]).arg(expected))); + } +} + +QTEST_MAIN(CameraRawCurveTests) +#include "CameraRawCurveTests.moc" diff --git a/tests/CameraRawGroupDrawTests.cpp b/tests/CameraRawGroupDrawTests.cpp index d2636c6..f5afb9e 100644 --- a/tests/CameraRawGroupDrawTests.cpp +++ b/tests/CameraRawGroupDrawTests.cpp @@ -89,7 +89,7 @@ void CameraRawGroupDrawTests::theCurvePagesShowTheirOwnControls() QCOMPARE(preset.toolTip(), QString("Replaces this curve with a straight line or a contrast curve.")); QCOMPARE(channel.toolTip(), QString("RGB changes brightness. Red, green, and blue also shift the color.")); QCOMPARE(group.child<QWidget>(QStringLiteral("cameraRawCurveGraph")).toolTip(), - QString("Drag a point. Click the curve to add one. Double-click a point to remove it.")); + QString("Drag a point. Click to add one. Double-click a point to remove it.")); QCOMPARE(group.child<QWidget>(QStringLiteral("curveSelectedPoint")).toolTip(), QString("Input and output of the selected curve point.")); QCOMPARE(group.child<QAbstractButton>(QStringLiteral("curveTargeted")).toolTip(), QString("Drag on the picture to move the curve for the tone under the pointer.")); @@ -126,20 +126,20 @@ void CameraRawGroupDrawTests::endPointsStayAndARealDoubleClickRemoves() group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; }); auto &graph = group.child<QWidget>(QStringLiteral("cameraRawCurveGraph")); const int w = graph.width(), h = graph.height(); - // With no inner point a drag moves nothing. + // An end point keeps its x; its output follows. QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(2, h - 2)); QMouseEvent move(QEvent::MouseMove, QPointF(40, 40), graph.mapToGlobal(QPointF(40, 40)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(&graph, &move); QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(40, 40)); - QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear()); - // An inner point is held within 0.02 and 0.98. + QCOMPARE(group.raw().curve.rgb.size(), size_t(2)); + QVERIFY(group.raw().curve.rgb[0].x == 0 && std::abs(group.raw().curve.rgb[0].y - (1 - 40.0 / h)) < 1e-9 && group.raw().curve.rgb[1] == (CurvePoint{1, 1})); + // Past the start: a hundredth in, then repaired away. group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(w / 2, h / 2)); QMouseEvent far(QEvent::MouseMove, QPointF(-50, h / 2), graph.mapToGlobal(QPointF(-50, h / 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(&graph, &far); QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(-50, h / 2)); - QCOMPARE(group.raw().curve.rgb.size(), size_t(3)); - QVERIFY(std::abs(group.raw().curve.rgb[1].x - 0.02) < 1e-9 && group.raw().curve.rgb.front().x == 0 && group.raw().curve.rgb.back().x == 1); + QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear()); // A real double click: press, release, double click, release. group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); doubleClick(graph, QPoint(w / 2, h / 2)); diff --git a/tests/CameraRawGroupRowsTests.cpp b/tests/CameraRawGroupRowsTests.cpp index 124dff0..877b4be 100644 --- a/tests/CameraRawGroupRowsTests.cpp +++ b/tests/CameraRawGroupRowsTests.cpp @@ -83,7 +83,7 @@ void CameraRawGroupRowsTests::curveRows() checkRow(*group.controls, group.session, row); group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; }); checkRow(*group.controls, group.session, - {"refineSaturation", "Refine Saturation", "How much the RGB curve also changes color strength. Zero keeps it to brightness.", -100, 100, 0, false, + {"refineSaturation", "Refine Saturation", "How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color.", -100, 100, 0, false, 48, 88, true, false, curve(&CameraRawCurveSettings::refineSaturation)}); } diff --git a/tests/CameraRawGroupWordsTests.cpp b/tests/CameraRawGroupWordsTests.cpp index a0e89f4..b0a10d9 100644 --- a/tests/CameraRawGroupWordsTests.cpp +++ b/tests/CameraRawGroupWordsTests.cpp @@ -88,7 +88,7 @@ void CameraRawGroupWordsTests::everyViewCarriesSwiftsWords() curve.show(); holds(*curve.controls, {"Parametric", "Point", "RGB", "Red", "Green", "Blue", "Parametric lifts tonal regions. Point places anchors on the curve.", "RGB changes brightness. Red, green, and blue also shift the color.", - "Drag a divider to change which tones the neighboring sliders affect.", "Targeted Adjustment", "Preset"}); + "Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers.", "Targeted Adjustment", "Preset"}); Group<CameraRawMixerControls> mixer; mixer.set([](CameraRawSettings &settings) { settings.mixer.points = {CameraRawPointColor{.hue = 30, .saturation = 1, .luminance = 0.5}}; }); mixer.show(); @@ -228,7 +228,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints() Group<CameraRawCurveControls> curve; curve.show(); auto &graph = curve.child<QWidget>(QStringLiteral("cameraRawCurveGraph")); - // Lights at 100 lift their region's middle by 0.22. + // The drawn curve is the model's parametric curve. curve.set([](CameraRawSettings &settings) { settings.curve.lights = 100; }); QImage drawn = graph.grab().toImage(); const int w = drawn.width(), h = drawn.height(); @@ -237,7 +237,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints() for (int y = 0; y < h; ++y) if (qGray(drawn.pixel(int(0.625 * w), y)) > qGray(drawn.pixel(int(0.625 * w), brightest))) brightest = y; - QVERIFY2(std::abs(brightest - (1 - 0.845) * h) < 3, qPrintable(QString::number(brightest))); + QVERIFY2(std::abs(brightest - (1 - curve.raw().curve.parametric(0.625)) * h) < 3, qPrintable(QString::number(brightest))); // A write repaints the graph without being asked. struct Paints : QObject { int count = 0; @@ -253,10 +253,14 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints() paints.count = 0; curve.set([](CameraRawSettings &settings) { settings.curve.darkSplit = 60; }); QTRY_VERIFY(paints.count > 0); - // The selected point is the last inner one. + // No point is chosen until one is pressed. curve.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; }); curve.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.2, 0.3}, {0.6, 0.7}, {1, 1}}; }); - QCOMPARE(curve.child<QLabel>(QStringLiteral("curveSelectedPoint")).text(), QString("In 153 Out 179")); + auto &readout = curve.child<QLabel>(QStringLiteral("curveSelectedPoint")); + QVERIFY(!readout.isVisible()); + QTest::mouseClick(&graph, Qt::LeftButton, {}, QPoint(int(0.6 * graph.width()), int(0.3 * graph.height()))); + QVERIFY(readout.isVisible()); + QCOMPARE(readout.text(), QString("In 153 Out 179")); } void CameraRawGroupWordsTests::widgetsFollowTheModelAndGuardsHold() diff --git a/tests/CameraRawWiringTests.cpp b/tests/CameraRawWiringTests.cpp index 9c934e0..7b20ffa 100644 --- a/tests/CameraRawWiringTests.cpp +++ b/tests/CameraRawWiringTests.cpp @@ -238,7 +238,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder() settings.mixer.points = {{200, 0.6, 0.4, 25, -35, 15, 40, 0.5, 0.3, false}, {40, 0.8, 0.6, -10, 20, -45, 60, 0.3, 0.6, false}}; settings.grading = {{210, 40, -20}, {100, 25, 10}, {30, 55, 15}, {300, 10, -5}, 70, -30}; const CameraRawCurveSettings shape = curve.normalized(); - const std::vector<float> luma = shape.lumaTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green), + const std::vector<float> tone = shape.toneTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green), blue = shape.channelTable(shape.blue); std::vector<float> mixer; for (size_t index = 0; index < 8; ++index) @@ -253,7 +253,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder() float(30 / 360.0), 0.55f, 0.15f, float(300 / 360.0), 0.1f, -0.05f}; for (const int visualize : {-1, 1}) { const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t stride) { - adjust_camera_raw_curve_color(bytes, w, h, stride, luma.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(), + adjust_camera_raw_curve_color(bytes, w, h, stride, tone.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(), grade.data(), 0.7, -0.3, visualize); }); QVERIFY(pixels(settings.apply(picture, std::nullopt, 1, 0, visualize)) == pixels(direct)); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 522dac3..6d28c75 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -168,6 +168,8 @@ compositor_test(ProjectWorkspaceFlowTests) compositor_test(QuitWhileEditingTextTests) compositor_test(QuitPendingEditsTests) compositor_test(ScanlinesTests) +compositor_test(CameraRawCurveTests) +compositor_test(CameraRawCurveGraphTests) compositor_test(OpenRecentTests) compositor_test(ColoredSliderTests) compositor_test(SheetTeardownTests) From 72230b09b86c1aa4cd9866b66ec6259effe976fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= <zhangyanghaha0407@outlook.com> Date: Thu, 1 Oct 2026 18:17:52 -0400 Subject: [PATCH 17/17] Merge 13.17 and 13.18: a dragged curve point stays on its neighbour, bands match one band, SheetTeardownTests on 13.4a's sheet; AGENTS and TASKS --- AGENTS.md | 2 ++ TASKS.md | 6 ++++-- src/UI/CameraRawColorControls+Curve.cpp | 7 +++++-- tests/CameraRawCurveGraphTests.cpp | 19 +++++++++++-------- tests/CameraRawGroupDrawTests.cpp | 5 +++-- tests/ScanlinesTests.cpp | 16 ++++++++++++++++ tests/SheetTeardownTests.cpp | 4 ++-- 7 files changed, 43 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d25f4ab..70cd78e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,6 +173,8 @@ Platform adaptations, each forced by a missing Qt analog: - Shift on a pixel drag and Select All from the list (13.10): `CanvasView::moveSelectionTool` (`EditorCanvas+Selection.cpp`) twins Swift's `60bde4f`: with Shift held a Ctrl-drag of selected pixels zeroes the shorter axis of the offset, lengths compared (`std::abs`), a tie keeping across, as `dragSelection` already does for the outline; read from each move's own modifiers, as Swift's `event.modifierFlags`. `451281e`: Swift's table now hands Select All to the canvas (`session.selectAll()`, guarded on a document) instead of selecting every row, so the twin's `NativeLayerList::selectAllRows` (12.5b's round-3 fix) and the menu's `focusedList` branch are gone: a focused list falls through to `session().selectAll()`, which guards on the document itself. A rename's editor is a `QLineEdit`, so it keeps its own Select All first, as Swift's field editor. Tests: `SelectionCanvasTests::shiftKeepsMovedPixelsOnALine` (across, down, a tie, a long left beating a short down, without Shift, the release keeping the last offset), `SelectMenuTests::selectAllInAFieldIsTheFields` (a real Ctrl+A on the focused list selects the canvas, history "Select All", layers and the active one unchanged, the list keeping focus; Swift's `selectAllInTheLayersPanelSelectsTheCanvas`). Mutants: 5 on the Shift branch, all caught (the `abs`-less comparison caught after the left-beats-down step was added). No survivors. - Why a brush cannot paint (13.11, `133c34a`): `EditorSession::paintRefusal` (`EditorSession+Brush.cpp`, declared in `EditorSession+Brush.h`) twins Swift's `paintRefusal`: none while `canEditLayers` refuses, without an active layer or when `canPaint` holds; then several layers, a folder's pixels, a hidden layer or one in a hidden folder, a mask turned off, an adjustment's pixels, an empty selection, in Swift's order and words, with Ctrl+D for ⌘D and the curly quotes kept. `beginBrush` splits Swift's guard: a tool that cannot paint the target (Spot Healing or Clone Stamp on a mask, a tool that is no brush) returns silently, a refused target sets `brushError` to the refusal (none when busy, so a waiting error is cleared, as Swift's `brushError = paintRefusal`). `beginWarp` sets the mask's words or the refusal. Fix beyond Swift: Swift's gradient sets the refusal only past its first guard, which is reached with a refused target only while a gradient is pending, where `canEditLayers` refuses and the refusal is always nil; so Swift's gradient says nothing on an empty selection, which its commit message says it explains. The twin's `beginGradient` sets the refusal at the first guard (no pending gradient, `canPaint` refusing); Swift's second assignment, always nil there, is left silent. `invertSelection` leaves no selection when the inverse is empty (Select All then Inverse), one `Inverse` step, as Swift's. Tests: `PaintRefusalTests` (every refusal and its order: two layers before a folder, a folder's mask turned off before the folder, an adjustment's mask before the adjustment, a layer inside a hidden folder; the gradient and the Smear; a busy session clearing, another tool silent), `SelectionTests::inverseOfEverythingDeselects` (Swift's). Equivalent mutants: `paintRefusal`'s `canPaint()` term (when `canPaint` holds every later test is false) and the empty-selection test without `isEmpty` (reached only when `canPaint` refuses for no listed reason, which needs an active layer outside `selectedLayerIDs`, which no session call makes). Painting at the layer's own resolution (13.11, `d3170ab`): `BrushStroke::Clone` is Swift's tuple `{image, placed, inGrid}`: the image painted through the tip and where it sits, in document pixels or in the stroke's own grid. `clonePixels` maps tile pixels through the grid (and `pixelToDocument` unless `inGrid`) into `placed`, scaled to the image, so a translation alone still copies or shifts by the fraction as before. `BrushStroke::gridRect` (in `BrushStroke+Commit.cpp`, beside `transform`, as Swift's) carries a document offset into the grid through the inverse map's linear part (`CGSize.applying`), turned, scaled and flipped as the layer. `cloneSample` takes the stroke and the offset: the stroke layer's own pixels placed at `gridRect(sourceRect, offset)`, or, sampling all layers, the composite at document size placed `offset` away; `blurSample` takes the stroke: the layer's pixels or mask drawn nearest at the margin of a region `ceil(3σ)` round `sourceRect`, σ the canvas's tenth of the brush (1.5 to 30) divided by the grid's document size per pixel (`sqrt|det|`) and capped at half the longer side, a mask on its edge tone first, then `PixelAdjust::gaussianBlur` (a mask's edge repeated, pixels clear past theirs) at σ × fit, where fit shrinks the sample to a budget of `min(maxSurfacePixels, max(16 M, four canvases))`; it returns the region as `placed`, `inGrid`. Swift's old mask sample (the placement's black box, `drawCoverage`) is gone with it. `beginBrush` makes the stroke first, then samples into it, as Swift's; a sample that fails returns without a stroke. The Smear's commit places its copy at document size. The brush and eraser: Swift's commit only changes its CPU fallback's stamp (`stampDiameter`), dead in the twin (8.1); the held judgment holds: the twin's coverage is rendered in the stroke's grid, so `NativeResolutionPaintTests` passes Swift's erase cases (scaled down, unequal, turned, and on the canvas scaled back up) and the blank layer's document resolution without any change to `BrushStroke::grid` or `BrushCoverage`. Tests: `NativeResolutionPaintTests` (Swift's seven, plus the offset carried through a flipped and a turned layer, pixel for pixel), `BlurToolTests` (`theSampleFollowsTheBrushSize`, `theSampleIsInTheLayersOwnPixels`: twice as fine, and a tiny layer's cap; `aHugeLayersSampleIsMadeCoarser`: 16 M and four canvases; `aMaskSampleKeepsItsEdgeTone`), the tests' document clones through `documentClone` (`BrushFixtures.h`). Cancel and Apply, and a scaled layer's row (13.11, `67cc31e`): `TransformInspector` puts Cancel and Apply in a row of their own (`pendingTransform`, spaced 12) that shows only while the edit waits (`transformEdit()->persistent`: typed values, Ctrl+T, a distortion begun so); a handle drag's edit leaves it unseen. Swift's `.opacity(0)` keeps the buttons in the layout and their keys alive, so the row keeps its place under a `QGraphicsOpacityEffect` faded over 0.12 s with CSS's ease-out (`QEasingCurve` Bézier 0, 0, 0.58, 1, SwiftUI's `.easeOut`), passes clicks through (`WA_TransparentForMouseEvents`, Swift's `allowsHitTesting(false)`) and takes no focus while unseen; Return and Escape still reach the edit through the bar, and a remapped key through `NativeShortcut` (the buttons stay `isVisible`). Known difference: Qt has no per-widget accessibility hiding short of hiding the widget, so a screen reader still finds the unseen buttons (Swift's `accessibilityHidden`). `sizeLabel(const ImageLayer &)` (declared in `NativeLayerList.h`, defined in `NativeLayerList+Cell.cpp`, Swift's extension in its UI file) gives the row "W × H px", and once the width differs from the asset's by a twentieth of a percent or more, " · " and the percent to one place at most, ties to even, in the user's locale as Swift's `.formatted`; a blank layer has no pixels to measure. Tests: `TransformInspectorTests::cancelAndApplyShowOnlyWhileAnEditWaits` (a drag's edit unseen, its click falling through and Return applying; a waiting edit fading in, clicked; Cancel), `theFieldsTakeTheSpareRoom` (the row's place), `NativeResolutionPaintTests::layersPanelSaysHowFarALayerIsScaled` (Swift's, the threshold either side, a rounded tenth, a blank layer), the row in `NativeLayerListTests`. Mutants (13.11): paint refusal 8 (2 survivors pinned by a folder's mask and an adjustment's mask, 2 equivalent as above), gradient 2, Smear 1, Inverse 1; `blurSample` 19 + 5 reruns (the unbuildable `1.0` replaced by `abs` for `sqrt`; the determinant's sign pinned by a layer turned 45°, the coarse sample's placement, size and σ × fit by an exact comparison of a 4200 × 3900 layer's sample), one survivor left untested: the `maxSurfacePixels` cap on the budget, reached only past a 50-million-pixel canvas with an 800 MB sample; `cloneSample` 4, `clonePixels` 3 (the scale pinned by `aGridCloneIsScaledOntoItsPlace`), `gridRect` 2, `beginBrush` 2 (a blank layer's Blur begins no stroke); `TransformInspector` 14, equivalent: the `pending == m_pending` guard (only restarts a fade toward the same end) and `setStartValue` (an animation without one starts from the property's value); `sizeLabel` 6 + 2 reruns, all caught. - Masks as Photoshop's (13.12, `b3419ab`): `addMask` with a selection now reveals it (white inside, black outside, the coverage as drawn) and, `revealing` false, hides it (the coverage inverted), one `Reveal Selection` or `Hide Selection` step; the row menu's Reveal All and Hide All call it as before, so Hide All with a selection hides the selection. `LayerMaskMenu` reads Alt from `QGuiApplication::keyboardModifiers()` when clicked, Swift's `NSApp.currentEvent`, so Alt-click adds the opposite, with Swift's help in Alt's words. The session keeps `viewsMaskAlone` (a getter and a setter that notifies on a change, the badge's write; `EditorSession.h`, defined in `EditorSession.cpp` beside Swift's `didSet`) and `maskAloneLayer()` (Swift's computed property: the view asked, the mask targeted, the active layer with a mask); `isMaskSelected` gains Swift's `didSet` as a private `setIsMaskSelected` (declared in `LayerMask+Session.h`, since `EditorSession.h` sits at 499 lines; defined in `EditorSession.cpp`), which every former write of `m_isMaskSelected` now calls, so targeting the pixels, another layer, a deleted mask or a load ends the view. `maskAloneLayer` and `toggleMaskAlone` live in `LayerMask+Alone.cpp`, since `LayerMask.cpp` already held 494 lines. `toggleMaskAlone` targets the mask through `selectLayerTarget` and shows it unless it already showed, refusing where that refuses. The canvas's `DisplayState` gains `maskAlone` (the mask asset's `ImageIdentity`, whether or not enabled, since the composite may skip it), and `drawLayers` hands off to `drawMaskAlone` (`EditorCanvas+Masks.cpp`, beside `liveFolderMaskClip`, which moved there to keep `EditorCanvas.cpp` under 500 lines): the canvas filled with the mask's edge tone, then a brush stroke or gradient laid into that mask through `LayerRenderer::drawBrushPreview` (the painted mask's own raster when it has one), else the mask at its displayed placement or the layer's transform, gray where it shows; the crisp zoom path draws through `drawLayers` too. The list's mask thumbnail: Alt's release without a drag calls `toggleMaskAlone` (Shift no longer toggles there, as Swift's), the tooltip names Alt-click, and the targeted ring is white while that mask shows alone (`setTargeted`'s `alone`; Swift's `maskAloneLayer?.id == layerID` is left out as dead: the ring shows only on the active row, whose layer is the one shown alone). `LayerListFocusTests` and `LayerDragGestureTests` expected Alt-Shift to toggle the mask; they now expect Swift's view. `MaskAloneBadge` (in `UI/LayerMaskMenu`, Swift's file) is 26 points tall, 75% black with a 14% white rim, radius 13: the mask glyph at 11, "Layer Mask" 12 px semibold, the layer's name 12 px at 60% white, elided within 220 points, and an `xmark` button (a new `LayerIcon`, Swift's bold `xmark` as two round-capped strokes) with Swift's tooltip in Alt's words and accessible name, which ends the view. `ContentView` keeps one badge, shown while `maskAloneLayer` holds, in a layout nested in the canvas slot (it starts hidden through `synchronize`, which the constructor runs) (bottom, centred, 14 points up), so the room beside and below it stays the canvas's; Qt child widgets take their own clicks, so Swift's AppKit hosting has no twin. Swift's GPU fix (a mask stroke's grid starting at the mask's background) is the twin's behaviour already: `allocateTile` fills a mask tile with `maskBackground`. Tests: `MaskAloneTests` (Swift's three, plus another layer ending the view and the setter's signal, a stroke and a gradient shown as committed, a disabled and a placed mask, the crisp zoom, the badge's words, place, elision and clicks, the list's Alt-click, Shift ignored and the white ring), `SelectionEditTests` (Swift's renamed two and the scaled layer's hiding mask), `LayersPanelTests::theMaskButtonUsesTheSelection` (plain and Alt clicks with and without a selection, the tooltips, the row's Hide All). Mutants: the session 10 + 1 rerun (the active-layer term of `toggleMaskAlone` pinned by a busy refusal with another layer's mask targeted), equivalent: `maskAloneLayer`'s `m_isMaskSelected` and `layer->mask` terms (every path that clears either clears the view through `setIsMaskSelected`; Swift's guards, kept); `drawMaskAlone` 6 + 3 reruns (the placement pinned off the old box), equivalent: the stroke's `isMask` and layer terms (a stroke while a mask shows alone is always that mask's: the view needs the mask targeted, and every target switch commits a pending gradient); the canvas branch and state 2; the list 4 + 2 (a release off the thumbnail pinned); the button and badge 5 + 3 (margins and spacing pinned); `ContentView` 5 (the constructor's `hide` was dead and is gone). All caught otherwise. +- Quit mid-edit and Scanlines (13.17, Swift 1.4.4). Quit (`1aad384`): `ProjectWorkspace::settlePendingEdits` twins Swift's: on the tab on screen a pending gradient is applied (`commitGradient`), then a pixel move (`finishPixelMove`), then a filter, Hue/Saturation, Levels, Color Range, the Expand/Contract/Feather panel (`setSelectionAmountOperation(nullopt)`) and the colour picker are cancelled (their Cancel, so nothing un-OK'd lands). It takes a completion callback (Swift's `async`), run from the event loop once both commits have landed, and only while the workspace lives (`QPointer`), the tab kept alive in the callbacks as every workspace flow keeps it. `confirmQuit` refuses at once while the workspace manages (Swift's guard moved there from `applicationShouldTerminate`; the twin's window close reaches `confirmQuit` directly, so this guard is what stops a second close mid-quit), applies open text, settles, and only then asks `canSwitch`; a refusal there (a busy project, a commit still running) is a warning on `compositor.app` where Swift beeps. Swift's `gradientEdit != nil` and `pixelMove != nil` tests before awaiting are left out: both twins return (posted) when there is nothing to commit. Swift's `finishAdjustmentEditing(commit: false)` is left out as dead: `cancelFilter`, `cancelHueSaturation` and `cancelLevels`, each called before it, call it first, and an adjustment's editor is always one of those three (its request and original are set and cleared with them; while the editor still renders, `finishAdjustmentEditing` does nothing in Swift either, and `canSwitch` refuses the quit). Tests: `QuitPendingEditsTests` (Swift's `quittingAppliesAPendingGradient` and `quittingCancelsAnOpenDialog`; a pixel move applied as one `Move Pixels` step; Color Range putting the selection back, the selection-amount panel, the colour picker not taking its colour; an adjustment's editor putting the settings back; a workspace gone mid-settle calling nothing; closing the window applying the gradient before the Save question; a busy project refused with the warning, nothing asked or applied; a second quit while the first asks refused unsettled). Scanlines (`9c99853`, `cd2f998`): `DitherStyle::scanlines` ("Scanlines (CRT)") joins the fourth group; `usesPixelSize` (false for ASCII and Scanlines: no chunky pixels, no Pixel Size or Pixel Shape rows) and `drawsMarks` without Scanlines, as Swift's. `DitherSettings` gains `lineSpacing` (2–32, rounded, 4), `glow` (0–100, 35), `dots` (0–100, 0) and `wobble` (0–64 px, unrounded, 0), normalized as Swift's; the kernel's cell is the line spacing for Scanlines. `kernels/DitherPixels.c/.h` are cd2f998's, with one adaptation: Swift's kernel threads through GCD (`dispatch_apply`) and Apple blocks, which GCC cannot compile. `in_bands` keeps Swift's split (one band under 64 items, else 32) but takes a C function and a context; the three block bodies (the tone pass, the scanlines, the glow) are moved word for word into static functions with context structs (`tone_job`, `scan_job`, `glow_job`), and `__block int failed` is a C11 `atomic_int`. The bands run through `dither_bands`, a C hook defined in `Document/Dither.cpp` on `PoolMap::blocking` (QtConcurrent, so a filter worker lends its pool place). The arithmetic is unchanged; this is a forced difference (GCC has no blocks or GCD), agreed with the coordinator. `bandsDrawAsOneBandDoes` holds 32 bands against one: the top rows of a 300-row picture equal a 62-row picture's, which runs as one band in both passes. `glowing` twins Swift's: sigma `lineSpacing × 3 + 3`, shrunk by `max(1, floor(sigma / 4))`, blurred at `sigma / shrink`, scaled back and added through `dither_glow` at `glow / 100 × 2.5`. Core Image clamps to the extent before it shrinks, so the twin pads the image by the blur's reach (three sigmas, a whole number of shrink steps) with its edges repeated, shrinks that with `QImage::scaled`'s smooth filter (an area average, where Core Image samples bilinearly), blurs with the edge repeated (`PixelAdjust::gaussianBlur`), scales back bilinearly and crops the middle; it stays within three levels of light of Swift's first commit's full-size blur (`theGlowIsTheBlurredLightAtItsAmount`, both axes). The sheet adds Line Spacing (px), Glow (%), Dots (%) and Wobble (px) with Swift's help under Scanlines, and Pixel Size and Pixel Shape follow `usesPixelSize`. Tests: `ScanlinesTests` (Swift's four `DitherTests`; the beam's rows for white, 0.35 gray and a striped average; the style's flags and that pixel size, shape and cell size change nothing; the four settings' clamps, rounding and defaults; Two Colors' screen and Original's black; clear pixels; every band of tall images, 32 bands and a short last one; the glow against a full-size Gaussian at 20% and 100%, its fade, and alpha; the sheet's rows, help, slider ends and fields), `DitherTests` and `DitherSheetTests` (the eleventh style in names, groups and the menu). Equivalent mutants: a pad of one sigma for three (the padded edge repeats the image's own edge, so the light differs by under a level), the shrunk blur without edge repetition (the pad keeps its reach off the edge), the kernel's `start < end` (a band past the end runs no line) and its band count (it only spreads the work). `settlePendingEdits` calls `done` directly: it already runs from the session's posted callback, after both commits. Untested path: the kernel's scan row that cannot be allocated (`failed`, a few kilobytes per band). Lead told of the GCD adaptation before it was made. the kernel cannot be copied unchanged (GCD and blocks); its threading is adapted, its arithmetic kept. Swift's beep on a refused quit is a warning on `compositor.app`. `SheetTeardownTests` called `CanvasSizeSheet` with its arguments from before 13.4a; fixed at the merge (a session passed), and it still fails when the sheet's `releaseFocus` goes (mutant caught). +- Camera Raw curves as Photoshop's (13.18, Swift 1.4.5: `60d9117`): `kernels/AdjustPixels.c/.h` are 60d9117's: the tone curve runs on red, green and blue alike (`toneLut`, Swift's rename of `lumaLut`), and Refine Saturation means what Swift's now means: 0 the curve per channel, below 0 easing toward the colour scaled to the curved brightness (−100 brightness alone), above 0 pushing the curved colour from its luminance by 1 + the amount. `CameraRawCurveSettings::parametric` twins Swift's: unchanged with all four amounts at 0; otherwise 33 anchors, each `bend` of `bend` (shadows and highlights past the outer dividers, then darks and lights either side of the middle one, gamma curves of strength 1.66), run through the same curve as Image › Curves (`point`), so the regions meet without a corner. `bend` is a private static, as Swift's; `lumaTable` is `toneTable`. The twin's normalization of the dividers (5–90, +2 apart, 98 at most) still clamps what a drag writes. The graph's gestures (`UI/CameraRawColorControls+Curve.cpp`, beside the file that would pass 500 lines) are Swift's single `DragGesture`: a left press picks once (`parametricDrag`: within 18 points of the bottom the nearest divider, the first of equals; elsewhere the region whose tone range holds the press, a tone on a divider joining the region above; `pointDrag`: the point nearest by distance within 0.055, chosen; else a new point there, if fewer than 16, inside x 0.01–0.99 and over a hundredth from every point's x, sorted in, stored and chosen), then `continueDrag` runs at the press and on every left-button move: a point's output follows the pointer, an inner point's x stays a hundredth inside its neighbours, an end keeps its x; a divider follows, shadow below dark − 2 and dark below light − 2; a region's amount is its start minus the drag's height over the graph's times 200, within ±100, rounded. Left out as dead, since normalizing does each (`CameraRawCurveSettings::normalized`: amounts, dividers 5–90, +2 apart, 98 at most, and the repair's 0–1 outputs): Swift's 2–98 clamp of a divider, dark's `shadow + 2` floor and light's `dark + 2` floor, and the 0–1 clamps of a dragged or new point's output (a press inside the graph is inside 0–1 anyway). The new point's `x > 0.01` and `x < 0.99` are dead too: the ends sit at 0 and 1, and a point within a hundredth of any x is refused. Swift's explicit refresh after the press is dead: every press writes the settings, which announce. `bend`'s `lower > 0` and `upper < 1` are dead (no tone lies below 0 or above 1). The release lets go. Qt can lose a release, so every press picks afresh. A double click is Qt's second press: it picks and drags as a press (Swift's drag sees it too), then on the point page removes the nearest inner point by x within 0.04 and drops the choice, and lets go of the drag (Swift removes at the tap's end, which ends the drag). Swift re-picks on each move while a press picked nothing; with the same start and points it picks nothing again, so the twin leaves that out. The chosen point (Swift's `selected`) draws in `Highlight`, the accent, and the readout shows only while it exists, following the drag; another channel drops the choice and the drag (Swift's `onChange`). The graph's help and Refine Saturation's are Swift's new words. Swift's `CameraRawMixerControls` gains the same `Drag` and `selected` state but never reads it: dead, left out. A fix beyond Swift: Swift holds an inner point exactly a hundredth from its neighbours, where the curve's repair (`x > kept.x + 0.01`) drops it (or drops the next point), and the drag's index then names another point; the twin holds it a billionth further inside, so the repair keeps it and the drag stays on it (`aPointIsPickedByDistanceAndKept`, `endPointsStayAndARealDoubleClickRemoves`, both failing before the fix). A fix beyond Swift: a preset's points drop the choice (TASKS asks for it; Swift's `applyPreset` keeps the index, which then lights whatever point holds it); Custom changes nothing and keeps it. Tests: `CameraRawCurveTests` (Swift's `parametricCurveIsSmooth`, `parametricCurveMatchesPhotoshop`, `curveDeepensColorLikePhotoshop`; each anchor against the bend written apart, and the smooth curve between; each region bending only its range, dividers at the ends bending nothing; the tone table through the point curve; Refine Saturation at 0 per channel through the table, −100 brightness with the hue kept, −50 halfway, +50 half again from luminance), `CameraRawCurveGraphTests` (regions lifted and lowered from their start, clamped and rounded, picked once by tone; dividers in the strip, nearest and first of equals, kept ordered and within the settings' limits; points by distance, kept through the drag, a new point added and dragged by the same press, ends moving their outputs alone; refusals near an edge, a point's x, at sixteen; the chosen point lit and read out as it moves, ends and new points included; a channel or preset dropping the choice, Custom keeping it, a held drag let go; double clicks removing an inner point and the choice, adding then removing on empty ground, ends staying; other buttons, a lost release, a release letting go; the help), and the existing `CameraRawColorTests`, `CameraRawCurveMixerTests`, `CameraRawGroupDrawTests`, `CameraRawGroupRowsTests`, `CameraRawGroupWordsTests` brought to the new rules and words. Equivalent mutant: `parametric`'s shortcut when all four amounts are 0 (33 anchors on the diagonal give the diagonal; it only spares the work). The kernel's refine branches, Swift's own code, were mutated too and all caught. Swift's `selected` survives a preset; TASKS asks for a preset to clear it, and a stale index lights a point the user never chose, so the twin clears it (a fix beyond Swift). An inner point dragged onto a neighbour vanished in Swift; fixed beyond Swift at the coordinator's request. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/TASKS.md b/TASKS.md index 5a63599..5102fbf 100644 --- a/TASKS.md +++ b/TASKS.md @@ -451,8 +451,10 @@ Reviews for this section ran on codex `gpt-6.1-sol` at `max` (agent `codex-sol`) - [x] 13.15 Part five: Layer › Ungroup Layers (Ctrl+Shift+G) and in a folder's context menu (`1318f1e`, `6c3b9a5`); Alt over a mask thumbnail shows the duplicate pointer with an eye (`6955a6f`). - 13.15 port (agent): tests in `GroupSessionTests` (Swift's three and more), `LayerContextMenuTests`, `CompositorMenusTests`, `KeyboardShortcutsTests` and `LayerDragGestureTests` (the eye pointer). Survivor recorded: the context entry gated by `canEditLayers` (equivalent: the right-click makes the folder active); Swift's validation guard left out as dead. No review yet. - [ ] 13.16 Versions 1.4.2–1.4.3: Liquify keeps pixels sharp (`7164dd4`), Smudge without ghost copies (`ad9ad7c`), the Blur brush's own Radius (`504129d`), Blur, Smudge and Liquify on big canvases (`095da2f`). -- [ ] 13.17 Version 1.4.4: quit works mid-gradient or with a dialog open (`1aad384`); Dither › Scanlines (CRT) on all cores (`9c99853`, `cd2f998`). -- [ ] 13.18 Version 1.4.5: Camera Raw curves as Photoshop's (`60d9117`): the smooth parametric curve and the per-channel point curves, Refine Saturation's new meaning (0 the RGB curve, −100 brightness alone, above 0 stronger colour) and its words; the graph: a press picks a point, divider or tonal region once and keeps it through the drag, a region drag lifts or lowers that region, dividers move in the bottom strip and stay ordered, point mode picks by distance, adds and drags a new point at once, keeps inner points ordered and moves the end points' outputs alone, the chosen point lit and the readout following it, a new channel or preset clearing the choice. +- [x] 13.17 Version 1.4.4: quit works mid-gradient or with a dialog open (`1aad384`); Dither › Scanlines (CRT) on all cores (`9c99853`, `cd2f998`). +- 13.17 port (agent): `settlePendingEdits` applies a pending gradient and pixel move and cancels open dialogs before Quit asks, `confirmQuit` refuses while managing; Scanlines (CRT) with Glow, Dots and Wobble, the kernel cd2f998's with GCD swapped for `dither_bands` on PoolMap (forced difference); tests in `QuitPendingEditsTests` and `ScanlinesTests`. Survivors recorded: the glow pad and edge mode, the band split. No review yet. +- [x] 13.18 Version 1.4.5: Camera Raw curves as Photoshop's (`60d9117`): the smooth parametric curve and the per-channel point curves, Refine Saturation's new meaning (0 the RGB curve, −100 brightness alone, above 0 stronger colour) and its words; the graph: a press picks a point, divider or tonal region once and keeps it through the drag, a region drag lifts or lowers that region, dividers move in the bottom strip and stay ordered, point mode picks by distance, adds and drags a new point at once, keeps inner points ordered and moves the end points' outputs alone, the chosen point lit and the readout following it, a new channel or preset clearing the choice. +- 13.18 port (agent): the fitted parametric curve, the tone curve per channel, Refine Saturation's new meaning and words; the graph's one drag (`CameraRawColorControls+Curve.cpp`); fixes beyond Swift: a preset drops the chosen point, a point dragged onto its neighbour stays and stays dragged; tests in `CameraRawCurveTests` and `CameraRawCurveGraphTests`. Survivor recorded: the all-zero shortcut. No review yet. - [ ] 13.19 Release OmaPhoto 1.4.5: version, change log, README, AGENTS.md, the acceptance run, distro checks, packages, live installs, the acceptance checklist. ## Skills by phase diff --git a/src/UI/CameraRawColorControls+Curve.cpp b/src/UI/CameraRawColorControls+Curve.cpp index 8052c46..2501d9f 100644 --- a/src/UI/CameraRawColorControls+Curve.cpp +++ b/src/UI/CameraRawColorControls+Curve.cpp @@ -70,8 +70,11 @@ void CameraRawCurveControls::continueDrag(QPointF at, QSizeF size) return; points[drag.index].y = y; // Ends stay ends; the rest keep their order. - if (drag.index > 0 && drag.index < points.size() - 1) - points[drag.index].x = std::min(points[drag.index + 1].x - 0.01, std::max(points[drag.index - 1].x + 0.01, x)); + if (drag.index > 0 && drag.index < points.size() - 1) { + // Fix beyond Swift: just inside, so the repair keeps it. + const double inside = 1e-9; + points[drag.index].x = std::min(points[drag.index + 1].x - 0.01 - inside, std::max(points[drag.index - 1].x + 0.01 + inside, x)); + } store(points); return; } diff --git a/tests/CameraRawCurveGraphTests.cpp b/tests/CameraRawCurveGraphTests.cpp index d95a673..0a0d29b 100644 --- a/tests/CameraRawCurveGraphTests.cpp +++ b/tests/CameraRawCurveGraphTests.cpp @@ -179,19 +179,22 @@ void CameraRawCurveGraphTests::aPointIsPickedByDistanceAndKept() QVERIFY(std::abs(graph.curve().rgb[2].y - 0.6) < 0.01 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); // Kept as it passes x 0.99, held before the end. graph.move(graph.at(1.2, 0.2)); - QVERIFY(std::abs(graph.curve().rgb[2].x - 0.99) < 1e-9 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); - // Onto its neighbour: a hundredth past, then repaired away. + QVERIFY(std::abs(graph.curve().rgb[2].x - 0.99) < 1e-6 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); + // Fix beyond Swift: onto its neighbour it stays, still dragged. graph.move(graph.at(0.1, 0.2)); - QCOMPARE(graph.curve().rgb.size(), size_t(3)); - QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); - graph.release(graph.at(0.1, 0.2)); - // Onto the next point: a hundredth short; that one goes. + QCOMPARE(graph.curve().rgb.size(), size_t(4)); + QVERIFY(std::abs(graph.curve().rgb[2].x - 0.31) < 1e-6 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); + graph.move(graph.at(0.6, 0.7)); + QCOMPARE(graph.curve().rgb.size(), size_t(4)); + QVERIFY(std::abs(graph.curve().rgb[2].x - 0.6) < 0.003 && graph.curve().rgb[3] == (CurvePoint{1, 1})); + graph.release(graph.at(0.6, 0.7)); + // Onto the next point: a hundredth short; both stay. graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.5, 0.5}, {1, 1}}; }); graph.press(graph.at(0.3, 0.3)); graph.move(graph.at(0.8, 0.3)); graph.release(graph.at(0.8, 0.3)); - QCOMPARE(graph.curve().rgb.size(), size_t(3)); - QVERIFY(std::abs(graph.curve().rgb[1].x - 0.49) < 1e-9); + QCOMPARE(graph.curve().rgb.size(), size_t(4)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.49) < 1e-6 && graph.curve().rgb[2] == (CurvePoint{0.5, 0.5})); // 0.05 away takes the point; 0.06 away adds one. graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); graph.press(graph.at(0.53, 0.54)); diff --git a/tests/CameraRawGroupDrawTests.cpp b/tests/CameraRawGroupDrawTests.cpp index f5afb9e..780a8d9 100644 --- a/tests/CameraRawGroupDrawTests.cpp +++ b/tests/CameraRawGroupDrawTests.cpp @@ -133,13 +133,14 @@ void CameraRawGroupDrawTests::endPointsStayAndARealDoubleClickRemoves() QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(40, 40)); QCOMPARE(group.raw().curve.rgb.size(), size_t(2)); QVERIFY(group.raw().curve.rgb[0].x == 0 && std::abs(group.raw().curve.rgb[0].y - (1 - 40.0 / h)) < 1e-9 && group.raw().curve.rgb[1] == (CurvePoint{1, 1})); - // Past the start: a hundredth in, then repaired away. + // Past the start it stays a hundredth in. group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(w / 2, h / 2)); QMouseEvent far(QEvent::MouseMove, QPointF(-50, h / 2), graph.mapToGlobal(QPointF(-50, h / 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(&graph, &far); QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(-50, h / 2)); - QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear()); + QCOMPARE(group.raw().curve.rgb.size(), size_t(3)); + QVERIFY(std::abs(group.raw().curve.rgb[1].x - 0.01) < 1e-6); // A real double click: press, release, double click, release. group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); doubleClick(graph, QPoint(w / 2, h / 2)); diff --git a/tests/ScanlinesTests.cpp b/tests/ScanlinesTests.cpp index 45f31ce..91d4af3 100644 --- a/tests/ScanlinesTests.cpp +++ b/tests/ScanlinesTests.cpp @@ -111,6 +111,7 @@ private slots: void theScreenIsTheDarkColourOrBlack(); void clearPixelsStayClear(); void everyBandOfATallImageIsDrawn(); + void bandsDrawAsOneBandDoes(); void theGlowIsTheBlurredLightAtItsAmount(); void theGlowNeverPassesAlpha(); void theSheetShowsScanlinesRows(); @@ -282,6 +283,21 @@ void ScanlinesTests::everyBandOfATallImageIsDrawn() QCOMPARE(tall, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 70)); } +void ScanlinesTests::bandsDrawAsOneBandDoes() +{ + // One band of 62 rows, 32 of 300: tops agree. + QImage noise = BrushRaster::context(7, 300, false); + for (int y = 0; y < 300; ++y) + for (int x = 0; x < 7; ++x) + noise.setPixelColor(x, y, QColor((x * 37 + y * 11) % 256, (x * 91 + y * 53) % 256, (y * 29) % 256, 128 + (x + y) % 128)); + DitherSettings settings = scanlines(2); + settings.colors = DitherColors::original; + settings.dots = 50; + settings.wobble = 5; + const QImage whole = settings.apply(noise), one = settings.apply(noise.copy(0, 0, 7, 62)); + QCOMPARE(whole.copy(0, 0, 7, 62), one); +} + void ScanlinesTests::theGlowIsTheBlurredLightAtItsAmount() { QImage image = filled(64, 160, Qt::black); diff --git a/tests/SheetTeardownTests.cpp b/tests/SheetTeardownTests.cpp index ed74f51..5199589 100644 --- a/tests/SheetTeardownTests.cpp +++ b/tests/SheetTeardownTests.cpp @@ -102,8 +102,8 @@ void SheetTeardownTests::theDocumentSheetsGoQuietly() { const CanvasDocument canvas{120, 80}; typeThenDestroy(std::make_unique<ImageSizeSheet>(canvas, [](std::optional<ImageSizeOptions>) {}), QStringLiteral("60")); - typeThenDestroy(std::make_unique<CanvasSizeSheet>(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {}), - QStringLiteral("60")); + EditorSession session; + typeThenDestroy(std::make_unique<CanvasSizeSheet>(canvas, session, [](std::optional<CanvasSizeOptions>) {}), QStringLiteral("60")); } void SheetTeardownTests::aCameraRawRowGoesQuietly()