From 9cce47908f159b447c9595cbd187a722a6971bf4 Mon Sep 17 00:00:00 2001 From: Uyanide Date: Fri, 27 Feb 2026 01:57:27 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20chekkupointo,=20far=20fro?= =?UTF-8?q?m=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 7 +- Tests/CMakeLists.txt | 24 -- Tests/tst_configmgr.cpp | 398 --------------------------- Tests/tst_imagemodel.cpp | 220 --------------- WallReel/Core/CMakeLists.txt | 1 - WallReel/Core/Config/data.hpp | 6 +- WallReel/Core/Config/manager.cpp | 35 ++- WallReel/Core/Config/manager.hpp | 49 +++- WallReel/Core/Image/data.cpp | 119 ++++++-- WallReel/Core/Image/data.hpp | 80 +++++- WallReel/Core/Image/model.cpp | 25 +- WallReel/Core/Image/model.hpp | 67 ++++- WallReel/Core/Image/provider.cpp | 24 -- WallReel/Core/Image/provider.hpp | 31 --- WallReel/Core/Palette/data.hpp | 17 ++ WallReel/Core/Palette/domcolor.hpp | 6 + WallReel/Core/Palette/manager.cpp | 23 +- WallReel/Core/Palette/manager.hpp | 37 ++- WallReel/Core/Palette/matchcolor.hpp | 7 + WallReel/Core/Palette/predefined.hpp | 25 ++ WallReel/Core/Service/wallpaper.cpp | 4 +- WallReel/Core/Utils/misc.hpp | 41 ++- WallReel/Core/appoptions.cpp | 27 +- WallReel/Core/appoptions.hpp | 3 + WallReel/UI/Modules/Carousel.qml | 2 +- WallReel/main.cpp | 8 +- 26 files changed, 476 insertions(+), 810 deletions(-) delete mode 100644 Tests/CMakeLists.txt delete mode 100644 Tests/tst_configmgr.cpp delete mode 100644 Tests/tst_imagemodel.cpp delete mode 100644 WallReel/Core/Image/provider.cpp delete mode 100644 WallReel/Core/Image/provider.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index fe906a6..801fe78 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,7 +25,7 @@ endif() configure_file(WallReel/version.h.in ${CMAKE_BINARY_DIR}/generated/version.h) -find_package(Qt6 REQUIRED COMPONENTS Quick Widgets QuickControls2 Concurrent Test) +find_package(Qt6 REQUIRED COMPONENTS Quick Widgets QuickControls2 Concurrent) qt_standard_project_setup(REQUIRES 6.5) @@ -37,11 +37,6 @@ option(ADDRESS_SANITIZER "Enable Address Sanitizer for debugging." OFF) add_subdirectory(WallReel/Core) add_subdirectory(WallReel/UI) -if(BUILD_TESTING) - enable_testing() - add_subdirectory(Tests) -endif() - if(ADDRESS_SANITIZER) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -g") set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address") diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt deleted file mode 100644 index c0a8b41..0000000 --- a/Tests/CMakeLists.txt +++ /dev/null @@ -1,24 +0,0 @@ - -project(Tests LANGUAGES CXX) - -find_package(Qt6 REQUIRED COMPONENTS Test) - -add_executable(tst_configmgr - tst_configmgr.cpp -) - -# add_executable(tst_imagemodel -# tst_imagemodel.cpp -# ) -add_test(NAME tst_configmgr COMMAND tst_configmgr) - -# add_test(NAME tst_imagemodel COMMAND tst_imagemodel) -target_link_libraries(tst_configmgr PRIVATE - Qt6::Test - ${CORELIB_NAME} -) - -# target_link_libraries(tst_imagemodel PRIVATE -# Qt6::Test -# ${CORELIB_NAME} -# ) diff --git a/Tests/tst_configmgr.cpp b/Tests/tst_configmgr.cpp deleted file mode 100644 index 0f7cc5c..0000000 --- a/Tests/tst_configmgr.cpp +++ /dev/null @@ -1,398 +0,0 @@ -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "Config/manager.hpp" - -using namespace WallReel::Core; - -class TestConfigMgr : public QObject { - Q_OBJECT - - private slots: - void initTestCase(); - void cleanupTestCase(); - - void testDefaults(); - void testFullConfigParsing(); - void testInvalidConfigValues(); - void testWallpaperScanRecursive(); - void testWallpaperScanNonRecursive(); - void testWallpaperExcludes(); - void testExplicitPaths(); - void testImageExtensions(); - void testSortTypes(); - - private: - QTemporaryDir m_tempDir; - QString m_configPath; - QString m_wallpaperRoot; - - void writeConfig(const QJsonObject& json); - void createDummyFile(const QString& relPath); -}; - -void TestConfigMgr::initTestCase() { - QVERIFY(m_tempDir.isValid()); - m_configPath = m_tempDir.path() + "/config.json"; - m_wallpaperRoot = m_tempDir.path() + "/wallpapers"; - QDir().mkpath(m_wallpaperRoot); -} - -void TestConfigMgr::cleanupTestCase() { -} - -void TestConfigMgr::writeConfig(const QJsonObject& json) { - QFile configFile(m_configPath); - QVERIFY(configFile.open(QIODevice::WriteOnly)); - configFile.write(QJsonDocument(json).toJson()); - configFile.close(); -} - -void TestConfigMgr::createDummyFile(const QString& relPath) { - QString absPath = m_wallpaperRoot + "/" + relPath; - QFileInfo fi(absPath); - QDir().mkpath(fi.absolutePath()); - QFile file(absPath); - QVERIFY(file.open(QIODevice::WriteOnly)); - file.write("foobar"); - file.close(); -} - -void TestConfigMgr::testDefaults() { - // Empty config file - writeConfig(QJsonObject()); - - Config::Manager config(m_tempDir.path(), {}, m_configPath); - - // Check Style Defaults - QCOMPARE(config.getImageWidth(), 320); - QCOMPARE(config.getImageHeight(), 200); - QCOMPARE(config.getImageFocusScale(), 1.5); - QCOMPARE(config.getWindowWidth(), 750); - QCOMPARE(config.getWindowHeight(), 500); - - // Check Sort Defaults - QCOMPARE(config.getSortConfig().type, Config::SortType::Name); - QCOMPARE(config.getSortConfig().reverse, false); - - // Check Action Defaults - QCOMPARE(config.getActionConfig().previewDebounceTime, 300); - QCOMPARE(config.getActionConfig().printSelected, false); - QCOMPARE(config.getActionConfig().printPreview, false); - QVERIFY(config.getActionConfig().onSelected.isEmpty()); - QVERIFY(config.getActionConfig().onPreview.isEmpty()); - QVERIFY(config.getActionConfig().onRestore.isEmpty()); - QVERIFY(config.getActionConfig().saveStateConfig.isEmpty()); -} - -void TestConfigMgr::testFullConfigParsing() { - QJsonObject root; - - // Wallpaper settings - QJsonObject wallpaperObj; - QJsonArray dirsArray; - QJsonObject dir1; - dir1["path"] = "/tmp/w1"; - dir1["recursive"] = true; - dirsArray.append(dir1); - wallpaperObj["dirs"] = dirsArray; - - QJsonArray pathsArray; - pathsArray.append("/tmp/p1.jpg"); - wallpaperObj["paths"] = pathsArray; - - QJsonArray excludesArray; - excludesArray.append(".*bad.*"); - wallpaperObj["excludes"] = excludesArray; - - root["wallpaper"] = wallpaperObj; - - // Palette - QJsonArray palettesArray; - QJsonObject palette1; - palette1["name"] = "Default"; - QJsonArray colorsArray; - QJsonObject color1; - color1["name"] = "Red"; - color1["value"] = "#FF0000"; - colorsArray.append(color1); - palette1["colors"] = colorsArray; - palettesArray.append(palette1); - root["palettes"] = palettesArray; - - // Action - QJsonObject actionObj; - actionObj["printSelected"] = true; - actionObj["onSelected"] = "echo {{ path }}"; - root["action"] = actionObj; - - // Style - QJsonObject styleObj; - styleObj["image_width"] = 100; - styleObj["image_height"] = 100; - root["style"] = styleObj; - - // Sort - QJsonObject sortObj; - sortObj["type"] = "date"; - sortObj["reverse"] = true; - root["sort"] = sortObj; - - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - - // Assertions - QCOMPARE(config.getWallpaperConfig().dirs.size(), 1); - QCOMPARE(config.getWallpaperConfig().dirs[0].path, "/tmp/w1"); - QCOMPARE(config.getWallpaperConfig().dirs[0].recursive, true); - - QCOMPARE(config.getWallpaperConfig().paths.size(), 1); - QCOMPARE(config.getWallpaperConfig().paths[0], "/tmp/p1.jpg"); - - QCOMPARE(config.getWallpaperConfig().excludes.size(), 1); - QCOMPARE(config.getWallpaperConfig().excludes[0].pattern(), ".*bad.*"); - - QCOMPARE(config.getPaletteConfig().palettes.size(), 1); - QCOMPARE(config.getPaletteConfig().palettes[0].name, "Default"); - QCOMPARE(config.getPaletteConfig().palettes[0].colors.size(), 1); - QCOMPARE(config.getPaletteConfig().palettes[0].colors[0].name, "Red"); - QCOMPARE(config.getPaletteConfig().palettes[0].colors[0].value.name().toLower(), "#ff0000"); - - QCOMPARE(config.getActionConfig().printSelected, true); - QCOMPARE(config.getActionConfig().onSelected, "echo {{ path }}"); - - QCOMPARE(config.getImageWidth(), 100); - QCOMPARE(config.getImageHeight(), 100); - - QCOMPARE(config.getSortConfig().type, Config::SortType::Date); - QCOMPARE(config.getSortConfig().reverse, true); -} - -void TestConfigMgr::testInvalidConfigValues() { - QJsonObject root; - QJsonObject styleObj; - styleObj["image_width"] = "not a number"; // Should be ignored - root["style"] = styleObj; - - QJsonObject sortObj; - sortObj["type"] = "invalid_type"; - root["sort"] = sortObj; - - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - - // Should retain defaults - QCOMPARE(config.getImageWidth(), 320); - QCOMPARE(config.getSortConfig().type, Config::SortType::Name); -} - -void TestConfigMgr::testWallpaperScanRecursive() { - // Setup files - createDummyFile("rec/root.jpg"); - createDummyFile("rec/sub/deep.png"); // should be found - createDummyFile("rec/ignore.txt"); // should be ignored - - QJsonObject root; - QJsonObject wallpaperObj; - QJsonArray dirsArray; - QJsonObject dirConfig; - dirConfig["path"] = m_wallpaperRoot + "/rec"; - dirConfig["recursive"] = true; - dirsArray.append(dirConfig); - wallpaperObj["dirs"] = dirsArray; - root["wallpaper"] = wallpaperObj; - - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - - QStringList wallpapers = config.getWallpapers(); - QCOMPARE(wallpapers.size(), 2); - - // Sort to verify presence - wallpapers.sort(); - // Paths are absolute - QVERIFY(wallpapers[0].endsWith("root.jpg")); - QVERIFY(wallpapers[1].endsWith("deep.png")); -} - -void TestConfigMgr::testWallpaperScanNonRecursive() { - createDummyFile("nonrec/root.jpg"); - createDummyFile("nonrec/sub/deep.png"); - - QJsonObject root; - QJsonObject wallpaperObj; - QJsonArray dirsArray; - QJsonObject dirConfig; - dirConfig["path"] = m_wallpaperRoot + "/nonrec"; - dirConfig["recursive"] = false; - dirsArray.append(dirConfig); - wallpaperObj["dirs"] = dirsArray; - root["wallpaper"] = wallpaperObj; - - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - - QStringList wallpapers = config.getWallpapers(); - QCOMPARE(wallpapers.size(), 1); - QVERIFY(wallpapers[0].endsWith("root.jpg")); -} - -void TestConfigMgr::testWallpaperExcludes() { - createDummyFile("excl/good.jpg"); - createDummyFile("excl/bad.jpg"); - - QJsonObject root; - QJsonObject wallpaperObj; - QJsonArray dirsArray; - QJsonObject dirConfig; - dirConfig["path"] = m_wallpaperRoot + "/excl"; - dirConfig["recursive"] = false; - dirsArray.append(dirConfig); - wallpaperObj["dirs"] = dirsArray; - - QJsonArray excludes; - excludes.append(".*bad\\.jpg$"); - wallpaperObj["excludes"] = excludes; - - root["wallpaper"] = wallpaperObj; - - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - - QStringList wallpapers = config.getWallpapers(); - QCOMPARE(wallpapers.size(), 1); - QVERIFY(wallpapers[0].endsWith("good.jpg")); -} - -void TestConfigMgr::testExplicitPaths() { - createDummyFile("explicit/a.jpg"); - QString absPath = m_wallpaperRoot + "/explicit/a.jpg"; - - QJsonObject root; - QJsonObject wallpaperObj; - QJsonArray pathsArray; - pathsArray.append(absPath); - wallpaperObj["paths"] = pathsArray; - root["wallpaper"] = wallpaperObj; - - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - - QStringList wallpapers = config.getWallpapers(); - QCOMPARE(wallpapers.size(), 1); - QCOMPARE(wallpapers[0], absPath); -} - -void TestConfigMgr::testImageExtensions() { - createDummyFile("ext/image1.jpg"); - createDummyFile("ext/image2.jpeg"); - createDummyFile("ext/image3.png"); - createDummyFile("ext/image4.bmp"); - createDummyFile("ext/text.txt"); - createDummyFile("ext/script.sh"); - createDummyFile("ext/noext"); - - QJsonObject root; - QJsonObject wallpaperObj; - QJsonArray dirsArray; - QJsonObject dirConfig; - dirConfig["path"] = m_wallpaperRoot + "/ext"; - dirConfig["recursive"] = false; - dirsArray.append(dirConfig); - wallpaperObj["dirs"] = dirsArray; - root["wallpaper"] = wallpaperObj; - - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - - QStringList wallpapers = config.getWallpapers(); - - int imageCount = 0; - for (const auto& w : wallpapers) { - if (w.endsWith(".txt") || w.endsWith(".sh") || w.endsWith("noext")) { - QFAIL(qPrintable("Found non-image file: " + w)); - } - imageCount++; - } - QVERIFY(imageCount >= 3); -} - -void TestConfigMgr::testSortTypes() { - // 1. None sort - { - QJsonObject root; - QJsonObject sortObj; - sortObj["type"] = "none"; - sortObj["reverse"] = false; - root["sort"] = sortObj; - - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - QCOMPARE(config.getSortConfig().type, Config::SortType::None); - QCOMPARE(config.getSortConfig().reverse, false); - } - // 2. Name sort (default) - { - QJsonObject root; - QJsonObject sortObj; - sortObj["type"] = "name"; - sortObj["reverse"] = true; - root["sort"] = sortObj; - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - QCOMPARE(config.getSortConfig().type, Config::SortType::Name); - QCOMPARE(config.getSortConfig().reverse, true); - } - // 3. Size sort - { - QJsonObject root; - QJsonObject sortObj; - sortObj["type"] = "size"; - root["sort"] = sortObj; - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - QCOMPARE(config.getSortConfig().type, Config::SortType::Size); - } - // 4. Date sort - { - QJsonObject root; - QJsonObject sortObj; - sortObj["type"] = "date"; - root["sort"] = sortObj; - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - QCOMPARE(config.getSortConfig().type, Config::SortType::Date); - } - // 5. Invalid sort -> fallback to default (Name) - { - QJsonObject root; - QJsonObject sortObj; - sortObj["type"] = "invalid_blah"; - root["sort"] = sortObj; - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - // Default initialized in Config constructor is Name - // But warning is logged - QCOMPARE(config.getSortConfig().type, Config::SortType::Name); - } - // 6. Case insensitivity for type string - { - QJsonObject root; - QJsonObject sortObj; - sortObj["type"] = "DaTe"; - root["sort"] = sortObj; - writeConfig(root); - Config::Manager config(m_tempDir.path(), {}, m_configPath); - QCOMPARE(config.getSortConfig().type, Config::SortType::Date); - } -} - -QTEST_MAIN(TestConfigMgr) -#include "tst_configmgr.moc" diff --git a/Tests/tst_imagemodel.cpp b/Tests/tst_imagemodel.cpp deleted file mode 100644 index 585f14c..0000000 --- a/Tests/tst_imagemodel.cpp +++ /dev/null @@ -1,220 +0,0 @@ -#include -#include -#include -#include - -#include "Config/manager.hpp" -#include "Image/model.hpp" -#include "Image/provider.hpp" - -using namespace WallReel::Core; - -class TestImageModel : public QObject { - Q_OBJECT - - private slots: - void initTestCase(); - void testSortName(); - void testSortDate(); - void testSortSize(); - - private: - QTemporaryDir m_tempDir; - QString m_pathA; - QString m_pathB; - QString m_pathC; - - void createTestFiles(); - void waitForModel(Image::Model* model); -}; - -// clang-format off -// xxd -i -static const unsigned char smallGIF[] = { - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0xf0, 0x00, - 0x00, 0xcd, 0xcf, 0xd2, 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, - 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b -}; -static const unsigned char mediumGIF[] = { - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x02, 0x00, 0x02, 0x00, 0xf1, 0x00, - 0x00, 0xb0, 0xb8, 0xc0, 0xb7, 0xbc, 0xc2, 0xd8, 0xdb, 0xda, 0xe2, 0xdd, - 0xdb, 0x21, 0xf9, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, - 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00, 0x02, 0x03, 0xd4, 0x10, 0x05, - 0x00, 0x3b -}; -static const unsigned char largeGIF[] = { - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x03, 0x00, 0x03, 0x00, 0xf3, 0x00, - 0x00, 0x80, 0x8b, 0x9c, 0xa9, 0xad, 0xac, 0xcf, 0xd5, 0xd6, 0xc9, 0xd2, - 0xdc, 0xde, 0xd7, 0xd8, 0xdf, 0xdf, 0xdf, 0xd3, 0xda, 0xe0, 0xe9, 0xea, - 0xeb, 0xf8, 0xf0, 0xee, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x21, 0xf9, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, - 0x00, 0x00, 0x03, 0x00, 0x03, 0x00, 0x00, 0x04, 0x07, 0xf0, 0x14, 0x24, - 0x02, 0x19, 0xc0, 0x44, 0x00, 0x3b -}; -// clang-format on - -void TestImageModel::initTestCase() { - createTestFiles(); -} - -void TestImageModel::createTestFiles() { - QVERIFY(m_tempDir.isValid()); - - // Create files with specific names, sizes, dates - // a.gif: medium size, medium date - // c.gif: small size, old date - // b.gif: big size, new date - // Note: Names are a.gif, b.gif, c.gif for name sort. - - m_pathA = m_tempDir.path() + "/a.gif"; - m_pathB = m_tempDir.path() + "/b.gif"; - m_pathC = m_tempDir.path() + "/c.gif"; - - { - QFile f(m_pathA); - QVERIFY(f.open(QIODevice::WriteOnly)); - f.write(reinterpret_cast(mediumGIF), sizeof(mediumGIF)); - f.close(); - } - - { - QFile f(m_pathB); - QVERIFY(f.open(QIODevice::WriteOnly)); - f.write(reinterpret_cast(largeGIF), sizeof(largeGIF)); - f.close(); - } - - { - QFile f(m_pathC); - QVERIFY(f.open(QIODevice::WriteOnly)); - f.write(reinterpret_cast(smallGIF), sizeof(smallGIF)); - f.close(); - } - - // Set times - - QDateTime now = QDateTime::currentDateTime(); - QDateTime timeOld = now.addDays(-10); - QDateTime timeMid = now.addDays(-5); - QDateTime timeNew = now; - - { - QFile f(m_pathC); - QVERIFY(f.open(QIODevice::ReadWrite)); - QVERIFY(f.setFileTime(timeOld, QFileDevice::FileModificationTime)); - } - { - QFile f(m_pathA); - QVERIFY(f.open(QIODevice::ReadWrite)); - QVERIFY(f.setFileTime(timeMid, QFileDevice::FileModificationTime)); - } - { - QFile f(m_pathB); - QVERIFY(f.open(QIODevice::ReadWrite)); - QVERIFY(f.setFileTime(timeNew, QFileDevice::FileModificationTime)); - } -} - -void TestImageModel::waitForModel(Image::Model* model) { - if (!model->isLoading()) { - return; - } - QSignalSpy spy(model, &Image::Model::isLoadingChanged); - while (model->isLoading()) { - if (!spy.wait(5000)) { - qWarning() << "Timeout waiting for model to load"; - break; - } - } -} - -void TestImageModel::testSortName() { - Config::SortConfigItems sortConfig; - sortConfig.type = Config::SortType::Name; - sortConfig.reverse = false; - - Image::Provider provider; - Image::Model model(provider, sortConfig, QSize(100, 100)); - - QStringList paths = {m_pathB, m_pathA, m_pathC}; // Unordered input - model.loadAndProcess(paths); - waitForModel(&model); - - QCOMPARE(model.rowCount(), 3); - - // Expected: a.gif, b.gif, c.gif - QCOMPARE(model.data(model.index(0), Image::Model::NameRole).toString(), "a.gif"); - QCOMPARE(model.data(model.index(1), Image::Model::NameRole).toString(), "b.gif"); - QCOMPARE(model.data(model.index(2), Image::Model::NameRole).toString(), "c.gif"); - - // Reverse - sortConfig.reverse = true; - model.sortUpdate(); - - QCOMPARE(model.rowCount(), 3); - - // Expected: c.gif, b.gif, a.gif - QCOMPARE(model.data(model.index(0), Image::Model::NameRole).toString(), "c.gif"); - QCOMPARE(model.data(model.index(1), Image::Model::NameRole).toString(), "b.gif"); - QCOMPARE(model.data(model.index(2), Image::Model::NameRole).toString(), "a.gif"); -} - -void TestImageModel::testSortDate() { - Config::SortConfigItems sortConfig; - sortConfig.type = Config::SortType::Date; - sortConfig.reverse = false; - - Image::Provider provider; - Image::Model model(provider, sortConfig, QSize(100, 100)); - - QStringList paths = {m_pathA, m_pathC, m_pathB}; - model.loadAndProcess(paths); - waitForModel(&model); - - QCOMPARE(model.rowCount(), 3); - - // Expected: c (old), a (mid), b (new) - QCOMPARE(model.data(model.index(0), Image::Model::NameRole).toString(), "c.gif"); - QCOMPARE(model.data(model.index(1), Image::Model::NameRole).toString(), "a.gif"); - QCOMPARE(model.data(model.index(2), Image::Model::NameRole).toString(), "b.gif"); - - // Reverse (Newest first) - sortConfig.reverse = true; - model.sortUpdate(); - - QCOMPARE(model.data(model.index(0), Image::Model::NameRole).toString(), "b.gif"); - QCOMPARE(model.data(model.index(1), Image::Model::NameRole).toString(), "a.gif"); - QCOMPARE(model.data(model.index(2), Image::Model::NameRole).toString(), "c.gif"); -} - -void TestImageModel::testSortSize() { - Config::SortConfigItems sortConfig; - sortConfig.type = Config::SortType::Size; - sortConfig.reverse = false; - - Image::Provider provider; - Image::Model model(provider, sortConfig, QSize(100, 100)); - - QStringList paths = {m_pathB, m_pathC, m_pathA}; - model.loadAndProcess(paths); - waitForModel(&model); - - QCOMPARE(model.rowCount(), 3); - - QCOMPARE(model.data(model.index(0), Image::Model::NameRole).toString(), "c.gif"); - QCOMPARE(model.data(model.index(1), Image::Model::NameRole).toString(), "a.gif"); - QCOMPARE(model.data(model.index(2), Image::Model::NameRole).toString(), "b.gif"); - - // Reverse - sortConfig.reverse = true; - model.sortUpdate(); - - QCOMPARE(model.data(model.index(0), Image::Model::NameRole).toString(), "b.gif"); - QCOMPARE(model.data(model.index(1), Image::Model::NameRole).toString(), "a.gif"); - QCOMPARE(model.data(model.index(2), Image::Model::NameRole).toString(), "c.gif"); -} - -QTEST_MAIN(TestImageModel) -#include "tst_imagemodel.moc" diff --git a/WallReel/Core/CMakeLists.txt b/WallReel/Core/CMakeLists.txt index 3c7a876..7d97d17 100644 --- a/WallReel/Core/CMakeLists.txt +++ b/WallReel/Core/CMakeLists.txt @@ -4,7 +4,6 @@ qt_add_qml_module(${CORELIB_NAME} SOURCES Image/data.hpp Image/data.cpp Image/model.hpp Image/model.cpp - Image/provider.hpp Image/provider.cpp Palette/data.hpp Palette/manager.hpp Palette/manager.cpp Palette/domcolor.hpp Palette/domcolor.cpp diff --git a/WallReel/Core/Config/data.hpp b/WallReel/Core/Config/data.hpp index bf196a4..31f59c0 100644 --- a/WallReel/Core/Config/data.hpp +++ b/WallReel/Core/Config/data.hpp @@ -33,7 +33,7 @@ // action.saveState array [] Useful for restore command // action.saveState[].key string "" Key of value to save, used as {{ key }} in onRestore command // action.saveState[].default string "" Value to save, used when "cmd" is not set or command execution fails or output is empty -// action.saveState[].cmd string "" Command that outputs(to stdout) the value to save when executed +// action.saveState[].command string "" Command that outputs(to stdout) the value to save when executed // action.saveState[].timeout number 3000 Timeout for executing "cmd" in milliseconds. 0 or negative means no timeout // action.onRestore string "" Command to execute on restore ({{ key }} -> value defined or obtained in saveState) // action.quitOnSelected boolean false Whether to quit the application after confirming a wallpaper @@ -91,8 +91,8 @@ struct ActionConfigItems { struct SaveStateItem { QString key; QString defaultVal; - QString cmd; - int timeout = 3000; + QString command; + int timeout = 3000; // milliseconds, 0 or negative means no timeout }; QList saveStateConfig; diff --git a/WallReel/Core/Config/manager.cpp b/WallReel/Core/Config/manager.cpp index 23acb4f..df5b57c 100644 --- a/WallReel/Core/Config/manager.cpp +++ b/WallReel/Core/Config/manager.cpp @@ -14,17 +14,19 @@ #include "logger.hpp" WallReel::Core::Config::Manager::Manager( - const QString& configDir, + const QDir& configDir, const QStringList& searchDirs, const QString& configPath, QObject* parent) : QObject(parent), m_configDir(configDir) { + // Load configPath if not empty, otherwise load from default location (configDir + s_DefaultConfigFileName) if (configPath.isEmpty()) { - Logger::info(QString("Configuration directory: %1").arg(configDir)); - _loadConfig(configDir + QDir::separator() + s_DefaultConfigFileName); + Logger::info(QString("Configuration directory: %1").arg(m_configDir.absolutePath())); + _loadConfig(m_configDir.absolutePath() + QDir::separator() + s_DefaultConfigFileName); } else { _loadConfig(configPath); } + // Append additional search directories to the config if (!searchDirs.isEmpty()) { Logger::info(QString("Additional search directories: %1").arg(searchDirs.join(", "))); for (const auto& dir : searchDirs) { @@ -73,7 +75,7 @@ void WallReel::Core::Config::Manager::_loadWallpaperConfig(const QJsonObject& ro if (config.contains("paths") && config["paths"].isArray()) { for (const auto& item : config["paths"].toArray()) { if (item.isString()) { - m_wallpaperConfig.paths.append(Utils::expandPath(item.toString())); + m_wallpaperConfig.paths.append(Utils::ensureAbsolutePath(Utils::expandPath(item.toString()))); } } } @@ -84,7 +86,7 @@ void WallReel::Core::Config::Manager::_loadWallpaperConfig(const QJsonObject& ro QJsonObject obj = item.toObject(); if (obj.contains("path") && obj["path"].isString()) { WallpaperConfigItems::WallpaperDirConfigItem dirConfig; - dirConfig.path = Utils::expandPath(obj["path"].toString()); + dirConfig.path = Utils::ensureAbsolutePath(Utils::expandPath(obj["path"].toString())); if (obj.contains("recursive") && obj["recursive"].isBool()) { dirConfig.recursive = obj["recursive"].toBool(); } else { @@ -193,8 +195,8 @@ void WallReel::Core::Config::Manager::_loadActionConfig(const QJsonObject& root) if (obj.contains("default") && obj["default"].isString()) { sItem.defaultVal = obj["default"].toString(); } - if (obj.contains("cmd") && obj["cmd"].isString()) { - sItem.cmd = obj["cmd"].toString(); + if (obj.contains("command") && obj["command"].isString()) { + sItem.command = obj["command"].toString(); } if (obj.contains("timeout") && obj["timeout"].isDouble()) { sItem.timeout = obj["timeout"].toInt(); @@ -310,6 +312,8 @@ void WallReel::Core::Config::Manager::_loadSortConfig(const QJsonObject& root) { void WallReel::Core::Config::Manager::_loadWallpapers() { m_wallpapers.clear(); + // Add paths first using a set to avoid duplicates + QSet paths; Logger::debug(QString("Loading wallpapers from %1 specified paths...").arg(m_wallpaperConfig.paths.size())); @@ -342,6 +346,8 @@ void WallReel::Core::Config::Manager::_loadWallpapers() { } } + // Exclude paths that match any of the exclude regexes + Logger::debug(QString("Excluding %1 specified paths...").arg(m_wallpaperConfig.excludes.size())); QStringList toRemove; for (const auto& exclude : std::as_const(m_wallpaperConfig.excludes)) { @@ -365,10 +371,15 @@ void WallReel::Core::Config::Manager::_loadWallpapers() { } } - Logger::info(QString("Found %1 files").arg(paths.size())); + Logger::info(QString("Found %1 images").arg(paths.size())); } void WallReel::Core::Config::Manager::captureState() { + if (m_pendingCaptures > 0) { + Logger::warn("State capture already in progress, ignoring new capture request"); + return; + } + m_pendingCaptures = 0; const auto& items = m_actionConfig.saveStateConfig; @@ -378,7 +389,7 @@ void WallReel::Core::Config::Manager::captureState() { } for (const auto& item : items) { - if (!item.cmd.isEmpty()) { + if (!item.command.isEmpty()) { m_pendingCaptures++; } } @@ -389,10 +400,10 @@ void WallReel::Core::Config::Manager::captureState() { } for (const auto& item : items) { - if (item.cmd.isEmpty()) continue; + if (item.command.isEmpty()) continue; QProcess* process = new QProcess(this); - QTimer* timer = nullptr; + QTimer* timer = nullptr; // Remains nullptr if no timeout is set for this item if (item.timeout > 0) { timer = new QTimer(this); timer->setSingleShot(true); @@ -451,7 +462,7 @@ void WallReel::Core::Config::Manager::captureState() { if (timer) { timer->start(); } - process->start("sh", QStringList() << "-c" << item.cmd); + process->start("sh", QStringList() << "-c" << item.command); } } diff --git a/WallReel/Core/Config/manager.hpp b/WallReel/Core/Config/manager.hpp index bae8f1b..e86c340 100644 --- a/WallReel/Core/Config/manager.hpp +++ b/WallReel/Core/Config/manager.hpp @@ -1,10 +1,21 @@ #ifndef WALLREEL_CONFIGMGR_HPP #define WALLREEL_CONFIGMGR_HPP +#include + #include "data.hpp" namespace WallReel::Core::Config { +/** + * @brief Config Manager (QML Singleton) + * + * @details Config Manager, which: + * - Loads and parses the configuration file + * - Provides access to configuration values via getters and Q_PROPERTY + * - Scans for wallpapers based on the configuration + * - Captures state when requested and emits a signal when done + */ class Manager : public QObject { Q_OBJECT @@ -15,17 +26,32 @@ class Manager : public QObject { Q_PROPERTY(int windowHeight READ getWindowHeight CONSTANT) public: + /** + * @brief Construct a new Manager object + * + * @param configDir The directory where the configuration file is located (should be the default location, i.e. $XDG_CONFIG_HOME/$appName) + * @param searchDirs Additional directories to search for wallpapers (not recursive) + * @param configPath Optional path to a specific configuration file (overrides the default config path) + * @param parent QObject parent + * + * @note The constructor will load the configuration and scan for wallpapers immediately. + */ Manager( - const QString& configDir, + const QDir& configDir, const QStringList& searchDirs = {}, - const QString& configPath = "", // Override the default config path + const QString& configPath = "", QObject* parent = nullptr); ~Manager(); + /** + * @brief Getter, Get the list of wallpapers found + * + * @return const QStringList& + */ const QStringList& getWallpapers() const { return m_wallpapers; } - qint64 getWallpaperCount() const { return m_wallpapers.size(); } + // Separate getters for each field in the configuration const WallpaperConfigItems& getWallpaperConfig() const { return m_wallpaperConfig; } @@ -37,6 +63,8 @@ class Manager : public QObject { const SortConfigItems& getSortConfig() const { return m_sortConfig; } + // Getters for Q_PROPERTY + int getImageWidth() const { return m_styleConfig.imageWidth; } int getImageHeight() const { return m_styleConfig.imageHeight; } @@ -47,27 +75,38 @@ class Manager : public QObject { int getWindowHeight() const { return m_styleConfig.windowHeight; } + /** + * @brief A quick snippet to get the focused image size as a QSize + * + * @return QSize + */ QSize getFocusImageSize() const { return QSize{m_styleConfig.imageWidth, m_styleConfig.imageHeight} * m_styleConfig.imageFocusScale; } + /** + * @brief Capture the current state of the configuration and emit stateCaptured() when done + */ Q_INVOKABLE void captureState(); signals: void stateCaptured(); private: + // Parse config void _loadConfig(const QString& configPath); - void _loadWallpapers(); void _loadWallpaperConfig(const QJsonObject& config); void _loadPaletteConfig(const QJsonObject& config); void _loadActionConfig(const QJsonObject& config); void _loadStyleConfig(const QJsonObject& config); void _loadSortConfig(const QJsonObject& config); + // Load wallpapers + void _loadWallpapers(); + // Callback for state capture results void _onCaptureResult(const QString& key, const QString& value); private: - const QString m_configDir; + const QDir m_configDir; WallpaperConfigItems m_wallpaperConfig; PaletteConfigItems m_paletteConfig; ActionConfigItems m_actionConfig; diff --git a/WallReel/Core/Image/data.cpp b/WallReel/Core/Image/data.cpp index 37940c8..1af3d4f 100644 --- a/WallReel/Core/Image/data.cpp +++ b/WallReel/Core/Image/data.cpp @@ -1,12 +1,16 @@ #include "data.hpp" +#include #include #include "Palette/domcolor.hpp" #include "logger.hpp" -WallReel::Core::Image::Data* WallReel::Core::Image::Data::create(const QString& path, const QSize& size) { - Data* ret = new Data(path, size); +WallReel::Core::Image::Data* WallReel::Core::Image::Data::create( + const QString& path, + const QSize& size, + const QDir& cacheDir) { + Data* ret = new Data(path, size, cacheDir); if (!ret->isValid()) { delete ret; return nullptr; @@ -14,12 +18,32 @@ WallReel::Core::Image::Data* WallReel::Core::Image::Data::create(const QString& return ret; } -WallReel::Core::Image::Data::Data(const QString& path, const QSize& targetSize) - : m_file(path) { - QImageReader reader(path); +WallReel::Core::Image::Data::Data(const QString& path, const QSize& targetSize, const QDir& cacheDir) + : m_file(path), m_targetSize(targetSize) { + m_id = _generateId(path, targetSize); + const auto cachePath = cacheDir.absoluteFilePath(_generateCacheFileName(m_id)); + m_cachedFile = QFileInfo(cachePath); + + // If cached file exists, use it directly + if (m_cachedFile.exists()) { + Logger::debug(QString("Cache hit for image: %1").arg(m_file.absoluteFilePath())); + if (!_loadFromCache()) { + Logger::warn(QString("Failed to load cached image from path: %1").arg(m_cachedFile.absoluteFilePath())); + if (!_loadFresh()) { + Logger::warn(QString("Failed to load fresh image from path: %1").arg(m_file.absoluteFilePath())); + } + } + } else { + if (!_loadFresh()) { + Logger::warn(QString("Failed to load fresh image from path: %1").arg(m_file.absoluteFilePath())); + } + } +} + +bool WallReel::Core::Image::Data::_loadFresh() { + QImageReader reader(m_file.absoluteFilePath()); if (!reader.canRead()) { - Logger::warn(QString("Failed to load image from path: %1").arg(path)); - return; + return false; } const QSize originalSize = reader.size(); @@ -27,40 +51,89 @@ WallReel::Core::Image::Data::Data(const QString& path, const QSize& targetSize) // Scale the image to fit the target size while maintaining aspect ratio QSize processSize = originalSize; if (originalSize.isValid()) { - double widthRatio = (double)targetSize.width() / originalSize.width(); - double heightRatio = (double)targetSize.height() / originalSize.height(); + double widthRatio = (double)m_targetSize.width() / originalSize.width(); + double heightRatio = (double)m_targetSize.height() / originalSize.height(); double scaleFactor = std::max(widthRatio, heightRatio); processSize = originalSize * scaleFactor; + // Use reader's built-in scaling if supported if (reader.supportsOption(QImageIOHandler::ScaledSize)) { reader.setScaledSize(processSize); } } - if (!reader.read(&m_image)) { - Logger::warn(QString("Failed to load image from path: %1").arg(path)); - return; + QImage image; + if (!reader.read(&image)) { + return false; } - if (m_image.width() > processSize.width() || m_image.height() > processSize.height()) { - m_image = m_image.scaled(processSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + // If reader doesn't support built-in scaling or the image still do not match the target size, do manual scaling + if (image.size() != processSize) { + image = image.scaled(processSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); } // Crop to target size if necessary - if (m_image.size() != targetSize) { - int x = (m_image.width() - targetSize.width()) / 2; - int y = (m_image.height() - targetSize.height()) / 2; - m_image = m_image.copy(x, y, targetSize.width(), targetSize.height()); + if (image.size() != m_targetSize) { + int x = (image.width() - m_targetSize.width()) / 2; + int y = (image.height() - m_targetSize.height()) / 2; + image = image.copy(x, y, m_targetSize.width(), m_targetSize.height()); } // Convert to GPU-friendly format - if (m_image.format() != QImage::Format_ARGB32_Premultiplied) { - m_image = m_image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + if (image.format() != QImage::Format_ARGB32_Premultiplied) { + image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); } - // Create ID - m_id = QString::number(qHash(m_file.absoluteFilePath())); + // Get dominant color + m_dominantColor = Palette::getDominantColor(image); + + // Save to cache + if (!image.save(m_cachedFile.absoluteFilePath())) { + Logger::warn(QString("Failed to save cached image to path: %1").arg(m_cachedFile.absoluteFilePath())); + return false; + } else { + Logger::debug(QString("Cached image saved to path: %1").arg(m_cachedFile.absoluteFilePath())); + } + return true; +} + +bool WallReel::Core::Image::Data::_loadFromCache() { + QImageReader reader(m_cachedFile.absoluteFilePath()); + if (!reader.canRead()) { + return false; + } + + QImage image; + if (!reader.read(&image)) { + return false; + } // Get dominant color - m_dominantColor = Palette::getDominantColor(m_image); + m_dominantColor = Palette::getDominantColor(image); + return true; +} + +QImage WallReel::Core::Image::Data::loadImage() const { + QImageReader reader(m_cachedFile.absoluteFilePath()); + if (!reader.canRead()) { + return QImage(); + } + + QImage image; + if (!reader.read(&image)) { + return QImage(); + } + return image; +} + +QString WallReel::Core::Image::Data::_generateId(const QString& path, const QSize& size) { + auto info = QFileInfo(path); + auto key = QString("%1|%2|%3|%4x%5").arg(info.absoluteFilePath()).arg(info.lastModified().toSecsSinceEpoch()).arg(info.size()).arg(size.width()).arg(size.height()); + + QByteArray hash = QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Md5); + return QString::fromLatin1(hash.toHex()); +} + +QString WallReel::Core::Image::Data::_generateCacheFileName(const QString& id) { + return id + ".png"; } diff --git a/WallReel/Core/Image/data.hpp b/WallReel/Core/Image/data.hpp index 7d72bf6..2e682bb 100644 --- a/WallReel/Core/Image/data.hpp +++ b/WallReel/Core/Image/data.hpp @@ -1,28 +1,86 @@ #ifndef WALLREEL_IMAGEDATA_HPP #define WALLREEL_IMAGEDATA_HPP +#include #include #include +#include + +// Development note +/* +Current implementation of image loading and caching: +1. Generate a unique ID for the image based on: + - File path + - Last modified timestamp + - Target size (width x height) + and use it as the cache key. +2. Check if a cached version of the image exists in the cache directory using the generated ID. + - If so, load the image from the cache and construct the Data object accordingly. + - If not: + a. Load the original image from disk. + b. Scale and crop it to the target size. + c. Save the processed image to the cache directory using the generated ID as the filename. + d. Construct the Data object with the new generated image. + +Why this approach - Main purposes +- Fast decoding: + By resizing and caching the image at the loading stage, the frontend can directly load the image + at a smaller size and avoid the overhead of downsizing large (8K+ for example) images in memory, + which can lead to significant performance improvements and reduced memory usage on the frontend. +- Memory efficiency: + - Avoid keeping pixel data in memory for all images, and only load on demand by the frontend. Even + keeping the resized image in memory can be costly if there are many, and the overhead of loading + small images from disk is generally negligible and acceptable. + - Resizing during loading fundamentally eliminates the possibility of the frontend storing large + images in memory. (and not all image formats support `sourceSize` property in the right way) + +Possible improvements: +- Cache other properties of the image (dominant color for example) to entirely avoid processing the + image in loading stage. A simple key-value store should be sufficient. + +*/ namespace WallReel::Core::Image { +/** + * @brief A Model class representing an image file + * + */ class Data { - QString m_id; - QFileInfo m_file; - QImage m_image; - QColor m_dominantColor; - QHash m_colorCache; + QString m_id; ///< Unique identifier for the image + QFileInfo m_file; ///< File information of the image + QFileInfo m_cachedFile; ///< Cached file information for the loaded image + QSize m_targetSize; ///< Target size for the loaded image + QColor m_dominantColor; ///< Dominant color of the image, used for palette matching + QHash m_colorCache; ///< Cache for palette color matching results, key is palette name, value is matched color name - Data(const QString& path, const QSize& size); + Data(const QString& path, const QSize& size, const QDir& cacheDir); + + bool _loadFromCache(); + + bool _loadFresh(); + + static QString _generateId(const QString& path, const QSize& size); + + static QString _generateCacheFileName(const QString& id); public: - static Data* create(const QString& path, const QSize& size); + /** + * @brief Factory method to create a Data instance from a file path. Returns nullptr if loading fails. + * + * @param path File path of the image + * @param size Target size for loaded image, the image will be scaled and cropped to this size and stored in memory + * @return Data* + */ + static Data* create(const QString& path, const QSize& size, const QDir& cacheDir); - const QImage& getImage() const { return m_image; } + QSize getTargetSize() const { return m_targetSize; } - const QString& getId() const { return m_id; } + QString getId() const { return m_id; } - bool isValid() const { return !m_image.isNull(); } + QUrl getUrl() const { return QUrl::fromLocalFile(m_cachedFile.absoluteFilePath()); } + + bool isValid() const { return m_cachedFile.exists(); } QString getFullPath() const { return m_file.absoluteFilePath(); } @@ -34,6 +92,8 @@ class Data { const QFileInfo& getFileInfo() const { return m_file; } + QImage loadImage() const; + const QColor& getDominantColor() const { return m_dominantColor; } std::optional getCachedColor(const QString& paletteName) const { diff --git a/WallReel/Core/Image/model.cpp b/WallReel/Core/Image/model.cpp index 2d51e10..994b657 100644 --- a/WallReel/Core/Image/model.cpp +++ b/WallReel/Core/Image/model.cpp @@ -7,13 +7,13 @@ #include "logger.hpp" WallReel::Core::Image::Model::Model( - Provider& provider, const Config::SortConfigItems& sortConfig, - QSize thumbnailSize, + const QDir& cacheDir, + const QSize& thumbnailSize, QObject* parent) : QAbstractListModel(parent), - m_provider(provider), m_sortConfig(sortConfig), + m_cacheDir(cacheDir), m_thumbnailSize(thumbnailSize), m_currentSortType(sortConfig.type) { connect( @@ -67,6 +67,8 @@ QVariant WallReel::Core::Image::Model::data(const QModelIndex& index, int role) switch (role) { case IdRole: return item->getId(); + case UrlRole: + return item->getUrl(); case PathRole: return item->getFullPath(); case NameRole: @@ -156,6 +158,8 @@ QVariant WallReel::Core::Image::Model::dataAt(int index, const QString& roleName const auto& item = m_data[actualIndex]; if (roleName == "imgId") { return item->getId(); + } else if (roleName == "imgUrl") { + return item->getUrl(); } else if (roleName == "imgPath") { return item->getFullPath(); } else if (roleName == "imgName") { @@ -177,13 +181,16 @@ void WallReel::Core::Image::Model::loadAndProcess(const QStringList& paths) { m_processedCount = 0; m_progressUpdateTimer.start(s_ProgressUpdateIntervalMs); + // These are all small objects so capturing by value should be fine const auto thumbnailSize = m_thumbnailSize; const auto counterPtr = &m_processedCount; - QFuture future = QtConcurrent::mapped(paths, [thumbnailSize, counterPtr](const QString& path) { - auto data = Data::create(path, thumbnailSize); - counterPtr->fetch_add(1, std::memory_order_relaxed); - return data; - }); + const auto cacheDir = m_cacheDir; + QFuture future = + QtConcurrent::mapped(paths, [thumbnailSize, counterPtr, cacheDir](const QString& path) { + auto data = Data::create(path, thumbnailSize, cacheDir); + counterPtr->fetch_add(1, std::memory_order_relaxed); + return data; + }); m_watcher.setFuture(future); emit totalCountChanged(); } @@ -224,7 +231,6 @@ int WallReel::Core::Image::Model::_convertProxyIndex(int proxyIndex) const { void WallReel::Core::Image::Model::_clearData() { beginResetModel(); - m_provider.clear(); qDeleteAll(m_data); m_data.clear(); for (auto& i : m_sortIndices) { @@ -323,7 +329,6 @@ void WallReel::Core::Image::Model::_onProcessingFinished() { for (auto& data : results) { if (data && data->isValid()) { m_data.append(data); - m_provider.insert(data); } else { Logger::warn("Failed to load image: " + (data ? data->getFullPath() : "null")); delete data; diff --git a/WallReel/Core/Image/model.hpp b/WallReel/Core/Image/model.hpp index e3a679b..02f78e5 100644 --- a/WallReel/Core/Image/model.hpp +++ b/WallReel/Core/Image/model.hpp @@ -1,26 +1,63 @@ #ifndef WALLREEL_IMAGEMODEL_HPP #define WALLREEL_IMAGEMODEL_HPP -#include - #include +#include #include #include #include #include "Config/data.hpp" -#include "provider.hpp" +#include "data.hpp" + +// Development note +/* +What "Proxy index" is: + +There are currently three layers of indices in the Model: +1. Actual index: The index of the image in the original data list (m_data), the order is not + guaranteed and can be considered random. +2. Sorted index: The index of the image after sorting, which is stored in m_sortIndices based on + different sort types. m_sortIndices are precomputed and does not change unless + m_data changes. In practice, the choise of which mapping from m_sortIndices to use + is determined by m_currentSortType. +3. Filtered index: The final mapping from the index exposed to the QML view to the actual data index, + which is stored in m_filteredIndices. m_filteredIndices is updated each time when + the sort type / sort order / search text changes, and only informs the layout to + update when its content actually changes. + +Therefore, when acquiring data, the "proxied" index must first be converted to the "actual" index +by looking up m_filteredIndices, and then the actual data can be accessed from m_data. + +*/ namespace WallReel::Core::Image { +/** + * @brief An unrefactored (view)model class that manages and provides the image list and properties of the focused image. + * + */ class Model : public QAbstractListModel { Q_OBJECT + // Controls which of the main screen and the loading screen should be shown + // and triggers callbacks when loading finished Q_PROPERTY(bool isLoading READ isLoading NOTIFY isLoadingChanged) + // Indicates the progress of loading, used to update the progress bar in the loading screen + // Not neccessarily updated on every image loaded, but should be updated frequently enough to make the progress bar smooth Q_PROPERTY(int processedCount READ processedCount NOTIFY progressChanged) + // Total count of images to be loaded, used to calculate the progress percentage Q_PROPERTY(int totalCount READ totalCount NOTIFY totalCountChanged) + // Sorting related properties + // How this works: + // 1. User interact with QML control components + // 2. QML calls the setter of the corresponding property in the Model + // 3. Model changes its internal state and update the sort indices accordingly + // 4. Model emits signal and possibly update state on QML side (for stateless controls) + // 5. ... Continue on further updates (search filter / focused image properties / etc) Q_PROPERTY(QString currentSortType READ currentSortType WRITE setCurrentSortType NOTIFY currentSortTypeChanged) Q_PROPERTY(bool currentSortReverse READ currentSortReverse WRITE setCurrentSortReverse NOTIFY currentSortReverseChanged) + // Focused image related properties, updated when focused image changed Q_PROPERTY(QString focusedName READ focusedName NOTIFY focusedNameChanged) public: @@ -28,6 +65,7 @@ class Model : public QAbstractListModel { enum Roles { IdRole = Qt::UserRole + 1, + UrlRole, PathRole, NameRole }; @@ -35,6 +73,7 @@ class Model : public QAbstractListModel { QHash roleNames() const override { return { {IdRole, "imgId"}, + {UrlRole, "imgUrl"}, // file:///... {PathRole, "imgPath"}, {NameRole, "imgName"}, }; @@ -43,9 +82,9 @@ class Model : public QAbstractListModel { // Constructor / Destructor Model( - Provider& provider, const Config::SortConfigItems& sortConfig, - QSize thumbnailSize, + const QDir& cacheDir, + const QSize& thumbnailSize, QObject* parent = nullptr); ~Model(); @@ -93,18 +132,25 @@ class Model : public QAbstractListModel { private: int _convertProxyIndex(int proxyIndex) const; void _clearData(); + // Update the corresponding mapping in m_sortIndices based on the current m_data and the given sort type void _updateSortIndices(Config::SortType type); + // Reobtain the properties of the focused image and emit corresponding signals void _updateFocusedProperties(); + // Update m_filteredIndices, only calls layoutAboutToBeChanged and layoutChanged when the filtered result + // actually changes and informView is true void _applySearchFilter(bool informView = true); signals: + // Properties void isLoadingChanged(); void progressChanged(); void totalCountChanged(); - void currentSortTypeChanged(); - void currentSortReverseChanged(); + void currentSortTypeChanged(); // -> _onSortMethodChanged + void currentSortReverseChanged(); // -> _onSortMethodChanged void focusedNameChanged(); - void searchTextChanged(); + // emitted after search text changed and the filter is applied + void searchTextChanged(); // -> _onSearchTextChanged + // emiited when the focued image (is believed to be) changed void focusedImageChanged(); private slots: @@ -114,8 +160,8 @@ class Model : public QAbstractListModel { void _onSearchTextChanged(); private: - Provider& m_provider; const Config::SortConfigItems& m_sortConfig; + QDir m_cacheDir; QSize m_thumbnailSize; QList m_data; @@ -131,9 +177,6 @@ class Model : public QAbstractListModel { // QTimer m_searchDebounceTimer; // static constexpr int s_SearchDebounceIntervalMs = 300; - QColor m_focusedColor{}; - QString m_focusedColorName{}; - QFutureWatcher m_watcher; bool m_isLoading = false; diff --git a/WallReel/Core/Image/provider.cpp b/WallReel/Core/Image/provider.cpp deleted file mode 100644 index d108d82..0000000 --- a/WallReel/Core/Image/provider.cpp +++ /dev/null @@ -1,24 +0,0 @@ - -#include "provider.hpp" - -QImage WallReel::Core::Image::Provider::requestImage(const QString& id, QSize* size, const QSize& requestedSize) { - QMutexLocker locker(&m_mutex); - if (!m_images.contains(id)) { - return QImage(); - } - Data* data = m_images[id]; - if (size) { - *size = data->getImage().size(); - } - return data->getImage(); -} - -void WallReel::Core::Image::Provider::insert(Data* data) { - QMutexLocker locker(&m_mutex); - m_images.insert(data->getId(), data); -} - -void WallReel::Core::Image::Provider::clear() { - QMutexLocker locker(&m_mutex); - m_images.clear(); -} diff --git a/WallReel/Core/Image/provider.hpp b/WallReel/Core/Image/provider.hpp deleted file mode 100644 index db2a1fd..0000000 --- a/WallReel/Core/Image/provider.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#ifndef WALLREEL_IMAGEPROVIDER_HPP -#define WALLREEL_IMAGEPROVIDER_HPP - -#include -#include -#include - -#include "data.hpp" - -namespace WallReel::Core::Image { - -class Provider : public QQuickImageProvider { - Q_OBJECT - - public: - Provider() : QQuickImageProvider(QQuickImageProvider::Image) {} - - QImage requestImage(const QString& id, QSize* size, const QSize& requestedSize) override; - - void insert(Data* data); - - void clear(); - - private: - QMutex m_mutex; - QHash m_images; -}; - -} // namespace WallReel::Core::Image - -#endif // WALLREEL_IMAGEPROVIDER_HPP diff --git a/WallReel/Core/Palette/data.hpp b/WallReel/Core/Palette/data.hpp index 03273c5..206c87b 100644 --- a/WallReel/Core/Palette/data.hpp +++ b/WallReel/Core/Palette/data.hpp @@ -22,6 +22,10 @@ struct ColorItem { bool operator==(const ColorItem& other) const { return name == other.name; } + + bool isValid() const { + return !name.isEmpty() && color.isValid(); + } }; struct PaletteItem { @@ -31,6 +35,8 @@ struct PaletteItem { public: QString name; + // We need to keep the order of colors, and the number of colors is usually limited, + // so a flat list and O(n) lookup should be fine here. QList colors; Q_INVOKABLE QColor getColor(const QString& colorName) const { @@ -40,9 +46,20 @@ struct PaletteItem { return QColor(); } + ColorItem getColorItem(const QString& colorName) const { + for (const auto& entry : colors) { + if (entry.name == colorName) return entry; + } + return ColorItem(); + } + bool operator==(const PaletteItem& other) const { return name == other.name; } + + bool isValid() const { + return !name.isEmpty() && !colors.isEmpty(); + } }; } // namespace WallReel::Core::Palette diff --git a/WallReel/Core/Palette/domcolor.hpp b/WallReel/Core/Palette/domcolor.hpp index fc27f76..2f763a6 100644 --- a/WallReel/Core/Palette/domcolor.hpp +++ b/WallReel/Core/Palette/domcolor.hpp @@ -5,6 +5,12 @@ namespace WallReel::Core::Palette { +/** + * @brief Get the dominant color of the given image. + * + * @param image The input image + * @return QColor An empty QColor() case error occurs, otherwise the dominant color of the image + */ QColor getDominantColor(const QImage& image); } // namespace WallReel::Core::Palette diff --git a/WallReel/Core/Palette/manager.cpp b/WallReel/Core/Palette/manager.cpp index c904f02..902dded 100644 --- a/WallReel/Core/Palette/manager.cpp +++ b/WallReel/Core/Palette/manager.cpp @@ -72,16 +72,12 @@ void WallReel::Core::Palette::Manager::updateColor() { if (!m_selectedColor.has_value()) { auto cached = imageData->getCachedColor(m_selectedPalette->name); if (cached.has_value()) { - auto it = std::find_if(m_selectedPalette->colors.begin(), - m_selectedPalette->colors.end(), - [&](const ColorItem& item) { - return item.name == cached.value(); - }); - if (it != m_selectedPalette->colors.end()) { + auto found = m_selectedPalette.value().getColorItem(cached.value()); + if (found.isValid()) { Logger::debug("Using cached color match for image " + imageData->getFileName() + - ": " + it->name); - m_displayColor = it->color; - m_displayColorName = it->name; + ": " + found.name); + m_displayColor = found.color; + m_displayColorName = found.name; hasResult = true; return; } @@ -89,6 +85,15 @@ void WallReel::Core::Palette::Manager::updateColor() { auto matched = bestMatch( imageData->getDominantColor(), m_selectedPalette.value().colors); + // Use dominant color if no valid match found (possibly empty palette) + if (!matched.isValid()) { + Logger::debug("No valid color match found for image " + imageData->getFileName() + + ", using dominant color: " + imageData->getDominantColor().name()); + m_displayColor = imageData->getDominantColor(); + m_displayColorName = ""; + hasResult = true; + return; + } Logger::debug("Computed color match for image " + imageData->getFileName() + ": " + matched.name); imageData->cacheColor(m_selectedPalette->name, matched.name); diff --git a/WallReel/Core/Palette/manager.hpp b/WallReel/Core/Palette/manager.hpp index 21499c0..deabe36 100644 --- a/WallReel/Core/Palette/manager.hpp +++ b/WallReel/Core/Palette/manager.hpp @@ -20,17 +20,15 @@ class Manager : public QObject { Image::Model& imageModel, QObject* parent = nullptr); - const QList& availablePalettes() const { - return m_palettes; - } + // Properties - const QColor& color() const { - return m_displayColor; - } + const QList& availablePalettes() const { return m_palettes; } - const QString& colorName() const { - return m_displayColorName; - } + const QColor& color() const { return m_displayColor; } + + const QString& colorName() const { return m_displayColorName; } + + // Setters Q_INVOKABLE void setSelectedPalette(const QVariant& paletteVar) { if (paletteVar.isNull() || !paletteVar.isValid()) { @@ -50,14 +48,32 @@ class Manager : public QObject { updateColor(); } + // Getters + + /** + * @brief Get the name of the currently selected palette + * + * @return QString The name of the currently selected palette, or an empty string if no palette is selected + */ QString getSelectedPaletteName() const { return m_selectedPalette ? m_selectedPalette->name : QString(); } + /** + * @brief Get the name of the currently selected color + * + * @return QString The name of the currently selected color, or an empty string if the color does not have + * a pretty name + */ QString getCurrentColorName() const { return m_displayColorName; } + /** + * @brief Get the hex string of the currently selected color + * + * @return QString The hex string of the currently selected color, or an empty string if the color is invalid + */ QString getCurrentColorHex() const { return m_displayColor.isValid() ? m_displayColor.name() @@ -65,7 +81,7 @@ class Manager : public QObject { } public slots: - void updateColor(); + void updateColor(); // <- Image::Model::focusedImageChanged signals: void colorChanged(); @@ -75,6 +91,7 @@ class Manager : public QObject { Image::Model& m_imageModel; QList m_palettes; + // Null means auto std::optional m_selectedPalette = std::nullopt; std::optional m_selectedColor = std::nullopt; diff --git a/WallReel/Core/Palette/matchcolor.hpp b/WallReel/Core/Palette/matchcolor.hpp index 5105920..9229ef7 100644 --- a/WallReel/Core/Palette/matchcolor.hpp +++ b/WallReel/Core/Palette/matchcolor.hpp @@ -5,6 +5,13 @@ namespace WallReel::Core::Palette { +/** + * @brief Find the best matching color from the candidates for the given target color. + * + * @param target + * @param candidates + * @return const ColorItem& The best matching color item, or an empty ColorItem if no candidates are provided + */ const ColorItem& bestMatch(const QColor& target, const QList& candidates); } // namespace WallReel::Core::Palette diff --git a/WallReel/Core/Palette/predefined.hpp b/WallReel/Core/Palette/predefined.hpp index 4d2b1eb..ff337c6 100644 --- a/WallReel/Core/Palette/predefined.hpp +++ b/WallReel/Core/Palette/predefined.hpp @@ -3,6 +3,31 @@ #include "data.hpp" +// License of Catppuccin - MIT +/* +MIT License + +Copyright (c) 2021 Catppuccin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + namespace WallReel::Core::Palette { inline const QList preDefinedPalettes = { diff --git a/WallReel/Core/Service/wallpaper.cpp b/WallReel/Core/Service/wallpaper.cpp index 8a87ac9..d915987 100644 --- a/WallReel/Core/Service/wallpaper.cpp +++ b/WallReel/Core/Service/wallpaper.cpp @@ -103,8 +103,8 @@ QHash WallReel::Core::Service::WallpaperService::_generateVari {"path", imageData.getFullPath()}, {"name", imageData.getFileName()}, {"size", QString::number(imageData.getSize())}, - {"width", QString::number(imageData.getImage().width())}, - {"height", QString::number(imageData.getImage().height())}, + {"width", QString::number(imageData.getTargetSize().width())}, + {"height", QString::number(imageData.getTargetSize().height())}, {"palette", palette}, {"color", color}, {"colorHex", hex}, diff --git a/WallReel/Core/Utils/misc.hpp b/WallReel/Core/Utils/misc.hpp index 3658337..d11cb86 100644 --- a/WallReel/Core/Utils/misc.hpp +++ b/WallReel/Core/Utils/misc.hpp @@ -87,6 +87,25 @@ inline QString expandPath(const QString& path) { return QDir::cleanPath(expandedPath); } +/** + * @brief Convert the given path to an absolute path. If it's already absolute, return as is. + * If it's relative, make it absolute based on the current working directory. + * + * @param path Input path + * @return QString Absolute path + * + * @note No guarantee that the returned path actually exists or is valid. + * @note Symbolic links are not resolved. + * @note The returned path is cleaned using QDir::cleanPath() + */ +inline QString ensureAbsolutePath(const QString& path) { + if (QDir::isAbsolutePath(path)) { + return path; + } else { + return QDir::cleanPath(QDir::current().filePath(path)); + } +} + /** * @brief Split the file name from a given path. * @@ -122,7 +141,12 @@ inline bool checkImageFile(const QString& filePath) { return formats.contains(ext.toUtf8()); } -inline QString getConfigDir() { +/** + * @brief Get the configuration directory for the application, and create it if it doesn't exist. + * + * @return QDir The configuration directory, typically ~/.config/AppName + */ +inline QDir getConfigDir() { // This will be ~/.config/AppName, where AppName is the name of executable target in CMakeLists.txt auto configDir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); if (configDir.isEmpty()) { @@ -132,6 +156,21 @@ inline QString getConfigDir() { return configDir; } +/** + * @brief Get the cache directory for the application, and create it if it doesn't exist. + * + * @return QDir The cache directory, typically ~/.cache/AppName + */ +inline QDir getCacheDir() { + // This will be ~/.cache/AppName, where AppName is the name of executable target in CMakeLists.txt + auto cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + if (cacheDir.isEmpty()) { + cacheDir = QDir::homePath() + QDir::separator() + ".cache" + QDir::separator() + APP_NAME; + } + QDir().mkpath(cacheDir); + return QDir(cacheDir); +} + } // namespace WallReel::Core::Utils #endif // WALLREEL_MISC_HPP diff --git a/WallReel/Core/appoptions.cpp b/WallReel/Core/appoptions.cpp index a1d0dd3..251edd3 100644 --- a/WallReel/Core/appoptions.cpp +++ b/WallReel/Core/appoptions.cpp @@ -32,6 +32,21 @@ void AppOptions::printHelp() { doReturn = true; } +// -C --clear-cache +void AppOptions::clearCache() { + QDir cacheDir = Utils::getCacheDir(); + if (cacheDir.exists()) { + if (cacheDir.removeRecursively()) { + Logger::info("Cache cleared successfully."); + } else { + Logger::warn("Failed to clear cache."); + } + } else { + Logger::info("Cache directory does not exist, nothing to clear."); + } + doReturn = true; +} + // Print error message and help void AppOptions::printError() { if (!errorText.isEmpty()) { @@ -53,6 +68,9 @@ void AppOptions::parseArgs(QApplication& app) { QCommandLineOption verboseOption(QStringList() << "V" << "verbose", "Set log level to DEBUG (default is INFO)"); parser.addOption(verboseOption); + QCommandLineOption clearCacheOption(QStringList() << "C" << "clear-cache", "Clear the image cache and exit"); + parser.addOption(clearCacheOption); + QCommandLineOption quietOption(QStringList() << "q" << "quiet", "Suppress all log output"); parser.addOption(quietOption); @@ -71,13 +89,18 @@ void AppOptions::parseArgs(QApplication& app) { return; } + if (parser.isSet(helpOption)) { + printHelp(); + return; + } + if (parser.isSet(versionOption)) { printVersion(); return; } - if (parser.isSet(helpOption)) { - printHelp(); + if (parser.isSet(clearCacheOption)) { + clearCache(); return; } diff --git a/WallReel/Core/appoptions.hpp b/WallReel/Core/appoptions.hpp index e127ad1..87e0d5d 100644 --- a/WallReel/Core/appoptions.hpp +++ b/WallReel/Core/appoptions.hpp @@ -19,6 +19,9 @@ class AppOptions { // -h --help void printHelp(); + // -C --clear-cache + void clearCache(); + // Print error message and help void printError(); diff --git a/WallReel/UI/Modules/Carousel.qml b/WallReel/UI/Modules/Carousel.qml index 8d9e7c4..67dc92b 100644 --- a/WallReel/UI/Modules/Carousel.qml +++ b/WallReel/UI/Modules/Carousel.qml @@ -66,7 +66,7 @@ Item { id: img anchors.fill: parent - source: "image://processed/" + model.imgId + source: model.imgUrl fillMode: Image.PreserveAspectFit asynchronous: true cache: true diff --git a/WallReel/main.cpp b/WallReel/main.cpp index adc38fb..2b9363b 100644 --- a/WallReel/main.cpp +++ b/WallReel/main.cpp @@ -6,7 +6,6 @@ #include "Core/Config/manager.hpp" #include "Core/Image/model.hpp" -#include "Core/Image/provider.hpp" #include "Core/Palette/data.hpp" #include "Core/Palette/manager.hpp" #include "Core/Service/manager.hpp" @@ -33,14 +32,11 @@ int main(int argc, char* argv[]) { QQmlApplicationEngine engine; - auto* imageProvider = new Image::Provider(); - engine.addImageProvider(QLatin1String("processed"), imageProvider); - auto config = new Config::Manager( Utils::getConfigDir(), s_options.appendDirs, s_options.configPath, - imageProvider); + &engine); qmlRegisterSingletonInstance( COREMODULE_URI, MODULE_VERSION_MAJOR, @@ -49,8 +45,8 @@ int main(int argc, char* argv[]) { config); auto imageModel = new Image::Model( - *imageProvider, config->getSortConfig(), + Utils::getCacheDir(), config->getFocusImageSize(), config); qmlRegisterSingletonInstance(