🚧 wip: chekkupointo, far from complete

This commit is contained in:
2026-02-27 01:57:27 +01:00
parent 2a338f251e
commit 9cce47908f
26 changed files with 476 additions and 810 deletions
+1 -6
View File
@@ -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")
-24
View File
@@ -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}
# )
-398
View File
@@ -1,398 +0,0 @@
#include <QtGui/qcolor.h>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTest>
#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"
-220
View File
@@ -1,220 +0,0 @@
#include <QDate>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QtTest>
#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 <file> -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<const char*>(mediumGIF), sizeof(mediumGIF));
f.close();
}
{
QFile f(m_pathB);
QVERIFY(f.open(QIODevice::WriteOnly));
f.write(reinterpret_cast<const char*>(largeGIF), sizeof(largeGIF));
f.close();
}
{
QFile f(m_pathC);
QVERIFY(f.open(QIODevice::WriteOnly));
f.write(reinterpret_cast<const char*>(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"
-1
View File
@@ -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
+3 -3
View File
@@ -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<SaveStateItem> saveStateConfig;
+23 -12
View File
@@ -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<QString> 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);
}
}
+44 -5
View File
@@ -1,10 +1,21 @@
#ifndef WALLREEL_CONFIGMGR_HPP
#define WALLREEL_CONFIGMGR_HPP
#include <QDir>
#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;
+96 -23
View File
@@ -1,12 +1,16 @@
#include "data.hpp"
#include <QCryptographicHash>
#include <QImageReader>
#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";
}
+70 -10
View File
@@ -1,28 +1,86 @@
#ifndef WALLREEL_IMAGEDATA_HPP
#define WALLREEL_IMAGEDATA_HPP
#include <QDir>
#include <QFileInfo>
#include <QImage>
#include <QUrl>
// 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<QString, QString> 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<QString, QString> 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<QString> getCachedColor(const QString& paletteName) const {
+12 -7
View File
@@ -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,10 +181,13 @@ 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<Data*> future = QtConcurrent::mapped(paths, [thumbnailSize, counterPtr](const QString& path) {
auto data = Data::create(path, thumbnailSize);
const auto cacheDir = m_cacheDir;
QFuture<Data*> 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;
});
@@ -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;
+55 -12
View File
@@ -1,26 +1,63 @@
#ifndef WALLREEL_IMAGEMODEL_HPP
#define WALLREEL_IMAGEMODEL_HPP
#include <qcontainerfwd.h>
#include <QAbstractListModel>
#include <QDir>
#include <QFutureWatcher>
#include <QTimer>
#include <atomic>
#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<int, QByteArray> 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<Data*> 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<Data*> m_watcher;
bool m_isLoading = false;
-24
View File
@@ -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();
}
-31
View File
@@ -1,31 +0,0 @@
#ifndef WALLREEL_IMAGEPROVIDER_HPP
#define WALLREEL_IMAGEPROVIDER_HPP
#include <QHash>
#include <QMutex>
#include <QQuickImageProvider>
#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<QString, Data*> m_images;
};
} // namespace WallReel::Core::Image
#endif // WALLREEL_IMAGEPROVIDER_HPP
+17
View File
@@ -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<ColorItem> 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
+6
View File
@@ -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
+14 -9
View File
@@ -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);
+27 -10
View File
@@ -20,17 +20,15 @@ class Manager : public QObject {
Image::Model& imageModel,
QObject* parent = nullptr);
const QList<PaletteItem>& availablePalettes() const {
return m_palettes;
}
// Properties
const QColor& color() const {
return m_displayColor;
}
const QList<PaletteItem>& 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<PaletteItem> m_palettes;
// Null means auto
std::optional<PaletteItem> m_selectedPalette = std::nullopt;
std::optional<ColorItem> m_selectedColor = std::nullopt;
+7
View File
@@ -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<ColorItem>& candidates);
} // namespace WallReel::Core::Palette
+25
View File
@@ -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<PaletteItem> preDefinedPalettes = {
+2 -2
View File
@@ -103,8 +103,8 @@ QHash<QString, QString> 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},
+40 -1
View File
@@ -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
+25 -2
View File
@@ -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;
}
+3
View File
@@ -19,6 +19,9 @@ class AppOptions {
// -h --help
void printHelp();
// -C --clear-cache
void clearCache();
// Print error message and help
void printError();
+1 -1
View File
@@ -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
+2 -6
View File
@@ -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(