#include "UI/OmarchyTheme.h" #include "Document/ProjectWorkspace.h" #include "UI/ProjectWorkspaceView.h" #include #include #include #include #include #include #include #include #include #include #include // The desktop's theme: read, applied, followed through a switch. namespace { const char *latte = R"(# Catppuccin Latte mode = "light" accent = "#1e66f5" # blue selection = "#ccd0da" muted = "#acb0be" background = "#eff1f5" dark_background = "#e3e4e8" darker_background = "#d7d8dc" lighter_background = "#dce0e8" foreground = "#4c4f69" dark_foreground = "#9ca0b0" light_foreground = "#5c5f77" bright_foreground = "#4c4f69" red = "#d20f39" yellow = "#df8e1d" orange = "#d84e2b" green = "#40a02b" )"; const char *macchiato = R"(mode = "dark" accent = "#8aadf4" selection = "#494d64" muted = "#5b6078" background = "#24273a" dark_background = "#1e2030" darker_background = "#181926" lighter_background = "#363a4f" foreground = "#cad3f5" dark_foreground = "#6e738d" light_foreground = "#b8c0e0" bright_foreground = "#cad3f5" red = "#ed8796" yellow = "#eed49f" )"; void write(const QString &path, const QString &text) { QDir().mkpath(QFileInfo(path).absolutePath()); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || file.write(text.toUtf8()) < 0) throw std::runtime_error("could not write " + path.toStdString()); } // While set, a colors.toml read gives 64 bytes, then fails. bool shortReads = false; // A window filled with the palette's window colour. struct Swatch { QLabel label; Swatch() { label.setAutoFillBackground(true); label.resize(40, 40); label.show(); } QRgb pixel() { return label.grab().toImage().pixel(20, 20); } }; } extern "C" ssize_t read(int descriptor, void *buffer, size_t count) { char path[512] = {}; const QByteArray link = "/proc/self/fd/" + QByteArray::number(descriptor); const bool theme = shortReads && readlink(link.constData(), path, sizeof(path) - 1) > 0 && QByteArray(path).endsWith("colors.toml"); static int reads = 0; if (theme && ++reads > 1) { errno = EIO; return -1; } if (!theme) reads = 0; return syscall(SYS_read, descriptor, buffer, theme ? std::min(count, 64) : count); } class OmarchyThemeTests : public QObject { Q_OBJECT private slots: void theFileParsesIntoColoursAndRefusesWhatIsMissingOrMalformed(); void lightAndDarkThemesFillThePalette(); void withoutAThemeTheBuiltInDarkApplies(); void aSwitchRetintsTheWindowsWhileTheyShow(); void anUnreadableSwitchKeepsThePalette(); void aMalformedFileAtStartupLeavesTheBuiltInDark(); void aFileThatCannotBeOpenedKeepsThePalette(); void anAbsentStateDirectoryIsWatchedForItsBirth(); void aBarredStateDirectoryIsWatchedFromAbove(); void aReadCutShortKeepsThePalette(); void aSwitchReachesTheWindowsTabsAndCanvas(); }; void OmarchyThemeTests::theFileParsesIntoColoursAndRefusesWhatIsMissingOrMalformed() { const OmarchyColors colours = OmarchyColors::parse(QString::fromLatin1(latte)); QVERIFY(!colours.dark); QCOMPARE(colours.accent, QColor(0x1e, 0x66, 0xf5)); QCOMPARE(colours.selection, QColor(0xcc, 0xd0, 0xda)); QCOMPARE(colours.muted, QColor(0xac, 0xb0, 0xbe)); QCOMPARE(colours.background, QColor(0xef, 0xf1, 0xf5)); QCOMPARE(colours.darkBackground, QColor(0xe3, 0xe4, 0xe8)); QCOMPARE(colours.darkerBackground, QColor(0xd7, 0xd8, 0xdc)); QCOMPARE(colours.lighterBackground, QColor(0xdc, 0xe0, 0xe8)); QCOMPARE(colours.foreground, QColor(0x4c, 0x4f, 0x69)); QCOMPARE(colours.darkForeground, QColor(0x9c, 0xa0, 0xb0)); QCOMPARE(colours.lightForeground, QColor(0x5c, 0x5f, 0x77)); QCOMPARE(colours.brightForeground, QColor(0x4c, 0x4f, 0x69)); QCOMPARE(colours.red, QColor(0xd2, 0x0f, 0x39)); // Orange is the warning where present, else yellow. QCOMPARE(colours.warning, QColor(0xd8, 0x4e, 0x2b)); const OmarchyColors dark = OmarchyColors::parse(QString::fromLatin1(macchiato)); QVERIFY(dark.dark); QCOMPARE(dark.warning, QColor(0xee, 0xd4, 0x9f)); QCOMPARE(dark.background, QColor(0x24, 0x27, 0x3a)); const auto refused = [](const QString &toml) { try { OmarchyColors::parse(toml); } catch (const std::runtime_error &error) { return QString::fromLatin1(error.what()); } return QString(); }; QVERIFY(refused(QString::fromLatin1(macchiato).replace("mode = \"dark\"", "mode = \"dim\"")).contains("mode")); QVERIFY(refused(QString::fromLatin1(macchiato).replace("accent = \"#8aadf4\"\n", "")).contains("accent")); QVERIFY(refused(QString::fromLatin1(macchiato).replace("#ed8796", "#ed879")).contains("red")); QVERIFY(refused(QString::fromLatin1(macchiato).replace("#ed8796", "ed8796")).contains("red")); QVERIFY(refused(QString()).contains("mode")); // Single quotes read the same. QCOMPARE(OmarchyColors::parse(QString::fromLatin1(latte).replace('"', '\'')).accent, QColor(0x1e, 0x66, 0xf5)); QCOMPARE(OmarchyColors::parse(QString::fromLatin1(macchiato).replace('"', '\'')).dark, true); QVERIFY(refused(QString::fromLatin1(macchiato).replace("\"dark\"", "'dark\"")).contains("mode")); // Quoted keys read too; bare or half-quoted values refuse. QCOMPARE(OmarchyColors::parse(QString::fromLatin1(macchiato).replace("mode = ", "\"mode\" = ")).dark, true); QCOMPARE(OmarchyColors::parse(QString::fromLatin1(macchiato).replace("accent = ", "'accent' = ")).accent, QColor(0x8a, 0xad, 0xf4)); QVERIFY(refused(QString::fromLatin1(macchiato).replace("mode = \"dark\"", "mode = dark")).contains("mode is no quoted")); QVERIFY(refused(QString::fromLatin1(latte).replace("orange = \"#d84e2b\"", "orange = 123")).contains("orange is no quoted")); QVERIFY(refused(QString::fromLatin1(latte).replace("orange = \"#d84e2b\"", "orange = \"#d84e2b'")).contains("orange")); QVERIFY(refused(QString::fromLatin1(latte).replace("red = \"#d20f39\"", "red = \"#d20f39\" \"twice\"")).contains("red is no quoted")); // A trailing comment and a bare unknown key are fine. QCOMPARE(OmarchyColors::parse(QString::fromLatin1(macchiato) + "hyprland_border = 1\n").red, QColor(0xed, 0x87, 0x96)); // A later line wins; an unknown key is ignored. const OmarchyColors twice = OmarchyColors::parse(QString::fromLatin1(macchiato) + "\nred = \"#000000\"\nhyprland_active_border = \"#123456\"\n"); QCOMPARE(twice.red, QColor(0, 0, 0)); } void OmarchyThemeTests::lightAndDarkThemesFillThePalette() { const QPalette light = OmarchyColors::parse(QString::fromLatin1(latte)).palette(); QCOMPARE(light.color(QPalette::Window), QColor(0xef, 0xf1, 0xf5)); QCOMPARE(light.color(QPalette::WindowText), QColor(0x4c, 0x4f, 0x69)); QCOMPARE(light.color(QPalette::Base), QColor(0xd7, 0xd8, 0xdc)); QCOMPARE(light.color(QPalette::AlternateBase), QColor(0xe3, 0xe4, 0xe8)); QCOMPARE(light.color(QPalette::Text), QColor(0x4c, 0x4f, 0x69)); QCOMPARE(light.color(QPalette::Button), QColor(0xdc, 0xe0, 0xe8)); QCOMPARE(light.color(QPalette::ButtonText), QColor(0x4c, 0x4f, 0x69)); QCOMPARE(light.color(QPalette::Highlight), QColor(0x1e, 0x66, 0xf5)); QCOMPARE(light.color(QPalette::HighlightedText), QColor(0xef, 0xf1, 0xf5)); QCOMPARE(light.color(QPalette::Link), QColor(0x1e, 0x66, 0xf5)); QCOMPARE(light.color(QPalette::PlaceholderText), QColor(0x9c, 0xa0, 0xb0)); QCOMPARE(light.color(QPalette::BrightText), QColor(0xd8, 0x4e, 0x2b)); QCOMPARE(light.color(QPalette::Mid), QColor(0xac, 0xb0, 0xbe)); QCOMPARE(light.color(QPalette::Dark), QColor(0xd7, 0xd8, 0xdc)); QCOMPARE(light.color(QPalette::Light), QColor(0xdc, 0xe0, 0xe8)); QCOMPARE(light.color(QPalette::ToolTipBase), QColor(0xe3, 0xe4, 0xe8)); QCOMPARE(light.color(QPalette::ToolTipText), QColor(0x4c, 0x4f, 0x69)); for (const QPalette::ColorRole role : {QPalette::WindowText, QPalette::Text, QPalette::ButtonText}) QCOMPARE(light.color(QPalette::Disabled, role), QColor(0xac, 0xb0, 0xbe)); const QPalette dark = OmarchyColors::parse(QString::fromLatin1(macchiato)).palette(); QCOMPARE(dark.color(QPalette::Window), QColor(0x24, 0x27, 0x3a)); QCOMPARE(dark.color(QPalette::HighlightedText), QColor(0x24, 0x27, 0x3a)); QCOMPARE(dark.color(QPalette::BrightText), QColor(0xee, 0xd4, 0x9f)); QCOMPARE(dark.color(QPalette::PlaceholderText), QColor(0x6e, 0x73, 0x8d)); } void OmarchyThemeTests::withoutAThemeTheBuiltInDarkApplies() { QTemporaryDir state; // Whatever style the desktop set, the palette needs Fusion. QApplication::setStyle(QStyleFactory::create(QStringLiteral("Windows"))); QCOMPARE(QApplication::style()->objectName(), QString("windows")); QTest::ignoreMessage(QtInfoMsg, QRegularExpression("no Omarchy theme at .*colors.toml - built-in dark")); OmarchyTheme theme(state.path()); QCOMPARE(theme.colors(), OmarchyColors::builtInDark()); QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0x24, 0x24, 0x24)); QCOMPARE(QApplication::palette().color(QPalette::Base), QColor(0x1b, 0x1b, 0x1b)); QCOMPARE(QApplication::palette().color(QPalette::PlaceholderText), QColor(0x8a, 0x8a, 0x8a)); QCOMPARE(QApplication::palette().color(QPalette::BrightText), QColor(0xff, 0x9f, 0x0a)); QCOMPARE(QApplication::style()->objectName(), QString("fusion")); Swatch swatch; QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24)); // A theme appearing later is taken up, unnamed or not. QTest::failOnWarning(QRegularExpression("QIODevice")); QTest::ignoreMessage(QtInfoMsg, QRegularExpression("Omarchy theme \\(unnamed\\) light from")); write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); } void OmarchyThemeTests::aSwitchRetintsTheWindowsWhileTheyShow() { QTemporaryDir state; write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); write(state.path() + "/theme.name", "catppuccin-macchiato\n"); QTest::ignoreMessage(QtInfoMsg, QRegularExpression("Omarchy theme catppuccin-macchiato dark from .*colors.toml")); OmarchyTheme theme(state.path()); Swatch swatch; QCOMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); // omarchy-theme-set: remove, move the next in, write the name. write(state.path() + "/next-theme/colors.toml", QString::fromLatin1(latte)); QVERIFY(QDir(state.path() + "/theme").removeRecursively()); QVERIFY(QDir().rename(state.path() + "/next-theme", state.path() + "/theme")); write(state.path() + "/theme.name", "catppuccin-latte\n"); QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); QVERIFY(!theme.colors().dark); QCOMPARE(QApplication::palette().color(QPalette::Highlight), QColor(0x1e, 0x66, 0xf5)); // Edited in place the file counts too, twice running. write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); // Replaced under a held handle, then edited: still seen. QFile held(state.path() + "/theme/colors.toml"); QVERIFY(held.open(QIODevice::ReadOnly)); write(state.path() + "/theme/next.toml", QString::fromLatin1(macchiato)); QCOMPARE(::rename(QFile::encodeName(state.path() + "/theme/next.toml").constData(), QFile::encodeName(state.path() + "/theme/colors.toml").constData()), 0); QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); } void OmarchyThemeTests::anUnreadableSwitchKeepsThePalette() { QTemporaryDir state; write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); OmarchyTheme theme(state.path()); Swatch swatch; QCOMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: colors.toml lacks accent")); write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato).replace("accent = \"#8aadf4\"\n", "")); QTest::qWait(400); QCOMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); QVERIFY(theme.colors().dark); // Mended, it applies again. write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); } void OmarchyThemeTests::aMalformedFileAtStartupLeavesTheBuiltInDark() { QTemporaryDir state; write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato).replace("mode = \"dark\"\n", "")); QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: colors.toml lacks mode")); OmarchyTheme theme(state.path()); QCOMPARE(theme.colors(), OmarchyColors::builtInDark()); Swatch swatch; QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24)); write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); } void OmarchyThemeTests::aFileThatCannotBeOpenedKeepsThePalette() { QTemporaryDir state; const QString path = state.path() + "/theme/colors.toml"; write(path, QString::fromLatin1(latte)); OmarchyTheme theme(state.path()); Swatch swatch; QCOMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); // A file that will not open is no absent file. QVERIFY(QFile::setPermissions(path, QFileDevice::Permissions())); QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: .*colors.toml")); theme.apply(); QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0xef, 0xf1, 0xf5)); QVERIFY(!theme.colors().dark); QVERIFY(QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner)); // An unsearchable folder hides the file: kept too. const QString folder = state.path() + "/theme"; QVERIFY(QFile::setPermissions(folder, QFileDevice::Permissions())); QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: .*colors.toml")); theme.apply(); QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0xef, 0xf1, 0xf5)); QVERIFY(QFile::setPermissions(folder, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); } void OmarchyThemeTests::anAbsentStateDirectoryIsWatchedForItsBirth() { QTemporaryDir root; const QString state = root.path() + "/omarchy/current"; QTest::ignoreMessage(QtInfoMsg, QRegularExpression("no Omarchy theme at .*colors.toml - built-in dark")); OmarchyTheme theme(state); Swatch swatch; QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24)); // Omarchy installed later: the theme arrives with its folders. auto &watcher = *theme.findChild(); QCOMPARE(watcher.directories(), QStringList{root.path()}); write(state + "/theme/colors.toml", QString::fromLatin1(latte)); QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); // Found, the state directory is watched, its ancestor let go. QVERIFY(watcher.directories().contains(state) && !watcher.directories().contains(root.path())); write(state + "/theme/colors.toml", QString::fromLatin1(macchiato)); QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); } void OmarchyThemeTests::aBarredStateDirectoryIsWatchedFromAbove() { QTemporaryDir root; const QString state = root.path() + "/current"; write(state + "/theme/colors.toml", QString::fromLatin1(latte)); QVERIFY(QFile::setPermissions(state, QFileDevice::Permissions())); QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: .*colors.toml")); OmarchyTheme theme(state); Swatch swatch; QCOMPARE(swatch.pixel(), qRgb(0x24, 0x24, 0x24)); auto &watcher = *theme.findChild(); QCOMPARE(watcher.directories(), QStringList{root.path()}); // Opened up again, the theme is found and followed. QVERIFY(QFile::setPermissions(state, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); QTRY_COMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); QTRY_VERIFY(watcher.directories().contains(state) && !watcher.directories().contains(root.path())); write(state + "/theme/colors.toml", QString::fromLatin1(macchiato)); QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); } void OmarchyThemeTests::aReadCutShortKeepsThePalette() { QTemporaryDir state; write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); OmarchyTheme theme(state.path()); Swatch swatch; QCOMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); // Sixty-four bytes, then an error: a prefix, not the file. write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); shortReads = true; QTest::ignoreMessage(QtWarningMsg, QRegularExpression("Omarchy theme unreadable, keeping the palette: read 64 of [0-9]+ bytes")); theme.apply(); QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0xef, 0xf1, 0xf5)); QCOMPARE(swatch.pixel(), qRgb(0xef, 0xf1, 0xf5)); shortReads = false; theme.apply(); QCOMPARE(QApplication::palette().color(QPalette::Window), QColor(0x24, 0x27, 0x3a)); // Widgets take the new palette from the event loop. QTRY_COMPARE(swatch.pixel(), qRgb(0x24, 0x27, 0x3a)); } void OmarchyThemeTests::aSwitchReachesTheWindowsTabsAndCanvas() { QTemporaryDir state; write(state.path() + "/theme/colors.toml", QString::fromLatin1(macchiato)); OmarchyTheme theme(state.path()); ProjectWorkspace workspace; ProjectWorkspaceView window(workspace); window.show(); QVERIFY(QTest::qWaitForWindowExposed(&window)); // The lightest or darkest pixel of a widget: its text. const auto extreme = [](QWidget &widget, bool lightest) { const QImage image = widget.grab().toImage(); int best = lightest ? 0 : 255; for (int y = 0; y < image.height(); ++y) { for (int x = 0; x < image.width(); ++x) best = lightest ? std::max(best, qGray(image.pixel(x, y))) : std::min(best, qGray(image.pixel(x, y))); } return best; }; auto &tab = *window.findChild("selectTab"); auto &canvas = *window.findChild("editorCanvas"); QVERIFY(std::abs(extreme(tab, true) - qGray(qRgb(0xca, 0xd3, 0xf5))) <= 12); QCOMPARE(canvas.grab().toImage().pixelColor(5, 5), QColor(0x18, 0x19, 0x26)); // Light: the tab's title turns dark, the canvas surround light. write(state.path() + "/theme/colors.toml", QString::fromLatin1(latte)); QTRY_COMPARE(canvas.grab().toImage().pixelColor(5, 5), QColor(0xd7, 0xd8, 0xdc)); QVERIFY(std::abs(extreme(tab, false) - qGray(qRgb(0x4c, 0x4f, 0x69))) <= 12); // The row's margin shows the toolbar, not a fill. auto &strip = *window.findChild("projectTabs"); const QImage bar = window.grab().toImage(); const QPoint inRow = strip.mapTo(&window, QPoint(6, 1)), beside = strip.mapTo(&window, QPoint(strip.width() + 8, 1)); QCOMPARE(bar.pixelColor(inRow), bar.pixelColor(beside)); } QTEST_MAIN(OmarchyThemeTests) #include "OmarchyThemeTests.moc"