commit ac476ca8
Put thumpnails in cache dir
Changed files
diff --git a/CMakeLists.txt b/CMakeLists.txt
index eed9c1b..1ac8fb2 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -14,8 +14,11 @@ qt_add_executable(SagoImageBrowser
src/imagemodel.h
src/thumbnailworker.cpp
src/thumbnailworker.h
+ src/thumbnailcache.cpp
+ src/thumbnailcache.h
src/imageviewwidget.cpp
- src/imageviewwidget.h src/preferencesdialog.cpp
+ src/imageviewwidget.h
+ src/preferencesdialog.cpp
src/preferencesdialog.h)
target_link_libraries(SagoImageBrowser PRIVATE Qt6::Widgets)
\ No newline at end of file
diff --git a/src/imagemodel.cpp b/src/imagemodel.cpp
index 622dbf6..6fb9368 100644
--- a/src/imagemodel.cpp
+++ b/src/imagemodel.cpp
@@ -32,6 +32,41 @@ void ImageModel::setCacheMaxSize(int n)
m_folderThumbnailCache.remove(m_cacheOrder.takeFirst());
}
+void ImageModel::setThumbnailSize(ThumbnailCache::Size size)
+{
+ if (size == m_thumbnailSize)
+ return;
+
+ m_thumbnailSize = size;
+
+ // Cancel any in-flight workers (they were started with the previous size).
+ m_cancelFlag = true;
+ m_threadPool.waitForDone();
+ m_cancelFlag = false;
+
+ m_pendingRows.clear();
+
+ // The folder cache is keyed by directory and size-agnostic; drop it so the
+ // user actually sees the new size.
+ m_folderThumbnailCache.clear();
+ m_cacheOrder.clear();
+
+ // Mark existing image entries unloaded so they get re-fetched at the new size.
+ if (!m_items.isEmpty())
+ {
+ for (int row = 0; row < m_items.size(); ++row)
+ {
+ Item &item = m_items[row];
+ if (item.isFolder)
+ continue;
+ item.thumbnail = m_placeholder;
+ item.loaded = false;
+ }
+ emit dataChanged(index(0), index(m_items.size() - 1),
+ {Qt::DecorationRole});
+ }
+}
+
void ImageModel::setDirectory(const QString &path)
{
m_cancelFlag = true;
@@ -142,6 +177,7 @@ void ImageModel::queueRow(int row)
auto *worker = new ThumbnailWorker(
m_items[row].path,
row,
+ m_thumbnailSize,
&m_cancelFlag);
connect(worker, &ThumbnailWorker::finished,
diff --git a/src/imagemodel.h b/src/imagemodel.h
index 0be4ed7..515de46 100644
--- a/src/imagemodel.h
+++ b/src/imagemodel.h
@@ -1,5 +1,7 @@
#pragma once
+#include "thumbnailcache.h"
+
#include <QAbstractListModel>
#include <QPixmap>
#include <QVector>
@@ -27,6 +29,7 @@ public:
void requestThumbnails(int firstRow, int lastRow);
void setCacheMaxSize(int n);
+ void setThumbnailSize(ThumbnailCache::Size size);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
@@ -60,4 +63,6 @@ private:
QHash<QString, ThumbnailMap> m_folderThumbnailCache;
QList<QString> m_cacheOrder;
int m_cacheMaxSize = 4;
+
+ ThumbnailCache::Size m_thumbnailSize = ThumbnailCache::Size::Normal;
};
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 2086083..ba2e828 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -2,6 +2,7 @@
#include "imagemodel.h"
#include "imageviewwidget.h"
#include "preferencesdialog.h"
+#include "thumbnailcache.h"
#include <QFileSystemModel>
#include <QTreeView>
@@ -26,6 +27,15 @@
#include <QSettings>
#include <iostream>
+namespace
+{
+ThumbnailCache::Size thumbnailSizeFromString(const QString &s)
+{
+ return (s == "large") ? ThumbnailCache::Size::Large
+ : ThumbnailCache::Size::Normal;
+}
+} // namespace
+
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
{
@@ -441,6 +451,8 @@ void MainWindow::loadPreferences()
applyDockLocking(m_lockDockingPreference);
m_folderCacheSizePreference = settings.value("folderCacheSize", 4).toInt();
m_imageModel->setCacheMaxSize(m_folderCacheSizePreference);
+ m_thumbnailCacheSizePreference = settings.value("thumbnailCacheSize", "normal").toString();
+ m_imageModel->setThumbnailSize(thumbnailSizeFromString(m_thumbnailCacheSizePreference));
if (settings.contains("windowGeometry"))
restoreGeometry(settings.value("windowGeometry").toByteArray());
if (settings.contains("windowState"))
@@ -453,6 +465,7 @@ void MainWindow::savePreferences()
settings.setValue("backgroundColor", m_backgroundColorPreference);
settings.setValue("lockDocking", m_lockDockingPreference);
settings.setValue("folderCacheSize", m_folderCacheSizePreference);
+ settings.setValue("thumbnailCacheSize", m_thumbnailCacheSizePreference);
settings.setValue("windowGeometry", saveGeometry());
settings.setValue("windowState", saveState());
}
@@ -463,6 +476,7 @@ void MainWindow::onPreferencesTriggered()
dialog.setBackgroundColor(m_backgroundColorPreference);
dialog.setLockDocking(m_lockDockingPreference);
dialog.setCacheSize(m_folderCacheSizePreference);
+ dialog.setThumbnailCacheSize(m_thumbnailCacheSizePreference);
bool shouldResetLayout = false;
connect(&dialog, &PreferencesDialog::resetLayoutRequested, this, [&shouldResetLayout]() {
@@ -477,6 +491,13 @@ void MainWindow::onPreferencesTriggered()
applyDockLocking(m_lockDockingPreference);
m_folderCacheSizePreference = dialog.getCacheSize();
m_imageModel->setCacheMaxSize(m_folderCacheSizePreference);
+ QString newThumbSize = dialog.getThumbnailCacheSize();
+ if (newThumbSize != m_thumbnailCacheSizePreference)
+ {
+ m_thumbnailCacheSizePreference = newThumbSize;
+ m_imageModel->setThumbnailSize(thumbnailSizeFromString(m_thumbnailCacheSizePreference));
+ loadVisibleThumbnails();
+ }
savePreferences();
}
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 39f127d..49ddee6 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -53,6 +53,7 @@ private:
QStackedWidget *m_stack;
QString m_backgroundColorPreference = "black";
int m_folderCacheSizePreference = 4;
+ QString m_thumbnailCacheSizePreference = "normal";
QWidget *m_browserPage;
ImageViewWidget *m_imageView;
bool m_wasMaximized = false;
diff --git a/src/preferencesdialog.cpp b/src/preferencesdialog.cpp
index a96b863..2b1c3ef 100644
--- a/src/preferencesdialog.cpp
+++ b/src/preferencesdialog.cpp
@@ -44,6 +44,16 @@ PreferencesDialog::PreferencesDialog(QWidget *parent)
cacheSizeLayout->addStretch();
groupLayout->addLayout(cacheSizeLayout);
+ QHBoxLayout *thumbSizeLayout = new QHBoxLayout;
+ QLabel *thumbSizeLabel = new QLabel("Thumbnail cache size:");
+ m_thumbnailSizeCombo = new QComboBox;
+ m_thumbnailSizeCombo->addItem("Normal (128 px)", "normal");
+ m_thumbnailSizeCombo->addItem("Large (256 px)", "large");
+ thumbSizeLayout->addWidget(thumbSizeLabel);
+ thumbSizeLayout->addWidget(m_thumbnailSizeCombo);
+ thumbSizeLayout->addStretch();
+ groupLayout->addLayout(thumbSizeLayout);
+
mainLayout->addWidget(imageGroup);
QGroupBox *layoutGroup = new QGroupBox("Layout", this);
@@ -98,6 +108,18 @@ void PreferencesDialog::setCacheSize(int n)
m_cacheSizeSpinBox->setValue(n);
}
+QString PreferencesDialog::getThumbnailCacheSize() const
+{
+ return m_thumbnailSizeCombo->currentData().toString();
+}
+
+void PreferencesDialog::setThumbnailCacheSize(const QString &size)
+{
+ int index = m_thumbnailSizeCombo->findData(size);
+ if (index >= 0)
+ m_thumbnailSizeCombo->setCurrentIndex(index);
+}
+
bool PreferencesDialog::getLockDocking() const
{
return m_lockDockingCheck->isChecked();
diff --git a/src/preferencesdialog.h b/src/preferencesdialog.h
index a6b79cf..f4dae5d 100644
--- a/src/preferencesdialog.h
+++ b/src/preferencesdialog.h
@@ -19,6 +19,9 @@ public:
int getCacheSize() const;
void setCacheSize(int n);
+ QString getThumbnailCacheSize() const;
+ void setThumbnailCacheSize(const QString &size);
+
bool getLockDocking() const;
void setLockDocking(bool lock);
@@ -31,6 +34,7 @@ signals:
private:
QComboBox *m_backgroundColorCombo;
QSpinBox *m_cacheSizeSpinBox;
+ QComboBox *m_thumbnailSizeCombo;
QCheckBox *m_lockDockingCheck;
QCheckBox *m_resetLayoutCheck;
};
diff --git a/src/thumbnailcache.cpp b/src/thumbnailcache.cpp
new file mode 100644
index 0000000..fd25ee2
--- /dev/null
+++ b/src/thumbnailcache.cpp
@@ -0,0 +1,137 @@
+#include "thumbnailcache.h"
+
+#include <QCryptographicHash>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QMimeDatabase>
+#include <QStandardPaths>
+#include <QTemporaryFile>
+#include <QUrl>
+
+namespace
+{
+constexpr const char *kSoftware = "SagoImageBrowser";
+
+QFile::Permissions filePerms()
+{
+ return QFile::ReadOwner | QFile::WriteOwner;
+}
+
+QFile::Permissions dirPerms()
+{
+ return QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner;
+}
+} // namespace
+
+QString ThumbnailCache::cacheRoot()
+{
+ QString base = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation);
+ if (base.isEmpty())
+ base = QDir::homePath() + "/.cache";
+ return base + "/thumbnails";
+}
+
+QString ThumbnailCache::subdirFor(Size size)
+{
+ const char *name = (size == Size::Large) ? "large" : "normal";
+ return cacheRoot() + "/" + name;
+}
+
+QString ThumbnailCache::canonicalUri(const QString &absPath)
+{
+ // Per the spec, use the absolute file URI (percent-encoded).
+ return QUrl::fromLocalFile(absPath).toString(QUrl::FullyEncoded);
+}
+
+QString ThumbnailCache::hashedName(const QString &canonicalUri)
+{
+ QByteArray hash = QCryptographicHash::hash(canonicalUri.toUtf8(),
+ QCryptographicHash::Md5)
+ .toHex();
+ return QString::fromLatin1(hash) + ".png";
+}
+
+bool ThumbnailCache::isInsideCache(const QString &absPath)
+{
+ QString abs = QFileInfo(absPath).absoluteFilePath();
+ QString root = QFileInfo(cacheRoot()).absoluteFilePath();
+ if (root.isEmpty())
+ return false;
+ return abs.startsWith(root + "/") || abs == root;
+}
+
+QImage ThumbnailCache::load(const QString &sourcePath, Size size)
+{
+ QFileInfo info(sourcePath);
+ if (!info.exists() || !info.isFile())
+ return {};
+
+ const QString uri = canonicalUri(info.absoluteFilePath());
+ const QString cachePath = subdirFor(size) + "/" + hashedName(uri);
+
+ QImage img;
+ if (!img.load(cachePath, "PNG"))
+ return {};
+
+ const qint64 mtime = info.lastModified().toSecsSinceEpoch();
+ bool ok = false;
+ const qint64 cachedMTime = img.text("Thumb::MTime").toLongLong(&ok);
+ if (!ok || cachedMTime != mtime)
+ return {};
+
+ return img;
+}
+
+void ThumbnailCache::save(const QString &sourcePath, Size size, const QImage &thumbImage)
+{
+ if (thumbImage.isNull())
+ return;
+
+ QFileInfo info(sourcePath);
+ if (!info.exists() || !info.isFile())
+ return;
+
+ const QString uri = canonicalUri(info.absoluteFilePath());
+ const QString subdir = subdirFor(size);
+ const QString finalPath = subdir + "/" + hashedName(uri);
+
+ QDir dir;
+ if (!dir.mkpath(subdir))
+ return;
+ QFile::setPermissions(subdir, dirPerms());
+ QFile::setPermissions(cacheRoot(), dirPerms());
+
+ QImage tagged = thumbImage;
+ tagged.setText("Thumb::URI", uri);
+ tagged.setText("Thumb::MTime",
+ QString::number(info.lastModified().toSecsSinceEpoch()));
+ tagged.setText("Thumb::Size", QString::number(info.size()));
+ QMimeDatabase mimeDb;
+ QString mime = mimeDb.mimeTypeForFile(info).name();
+ if (!mime.isEmpty())
+ tagged.setText("Thumb::Mimetype", mime);
+ tagged.setText("Thumb::Image::Width", QString::number(thumbImage.width()));
+ tagged.setText("Thumb::Image::Height", QString::number(thumbImage.height()));
+ tagged.setText("Software", QString::fromLatin1(kSoftware));
+
+ QTemporaryFile tmp(subdir + "/tmp_XXXXXX.png");
+ tmp.setAutoRemove(false);
+ if (!tmp.open())
+ return;
+ const QString tmpPath = tmp.fileName();
+ tmp.close();
+
+ if (!tagged.save(tmpPath, "PNG"))
+ {
+ QFile::remove(tmpPath);
+ return;
+ }
+
+ QFile::setPermissions(tmpPath, filePerms());
+
+ // Atomic replace: remove existing target then rename.
+ QFile::remove(finalPath);
+ if (!QFile::rename(tmpPath, finalPath))
+ QFile::remove(tmpPath);
+}
diff --git a/src/thumbnailcache.h b/src/thumbnailcache.h
new file mode 100644
index 0000000..67b9155
--- /dev/null
+++ b/src/thumbnailcache.h
@@ -0,0 +1,29 @@
+#pragma once
+
+#include <QString>
+#include <QImage>
+
+class ThumbnailCache
+{
+public:
+ enum class Size
+ {
+ Normal = 128,
+ Large = 256,
+ };
+
+ // Returns a non-null QImage if a valid cached thumbnail exists for sourcePath
+ // at the requested size. Validity is determined by comparing the original
+ // file's mtime to the Thumb::MTime tEXt chunk in the cached PNG.
+ static QImage load(const QString &sourcePath, Size size);
+
+ // Atomically writes thumbImage to the cache with the required Freedesktop
+ // tEXt chunks. No-op if sourcePath cannot be stat'ed.
+ static void save(const QString &sourcePath, Size size, const QImage &thumbImage);
+
+ static QString cacheRoot();
+ static QString subdirFor(Size size);
+ static QString canonicalUri(const QString &absPath);
+ static QString hashedName(const QString &canonicalUri);
+ static bool isInsideCache(const QString &absPath);
+};
diff --git a/src/thumbnailworker.cpp b/src/thumbnailworker.cpp
index 5a209ab..04868a5 100644
--- a/src/thumbnailworker.cpp
+++ b/src/thumbnailworker.cpp
@@ -1,12 +1,32 @@
#include "thumbnailworker.h"
+
+#include "thumbnailcache.h"
+
#include <QImageReader>
-#include <QPainter>
+
+namespace
+{
+constexpr int kDisplaySize = 128;
+
+QImage downscaleForDisplay(const QImage &src)
+{
+ if (src.isNull())
+ return src;
+ if (src.width() <= kDisplaySize && src.height() <= kDisplaySize)
+ return src;
+ return src.scaled(kDisplaySize, kDisplaySize,
+ Qt::KeepAspectRatio,
+ Qt::SmoothTransformation);
+}
+} // namespace
ThumbnailWorker::ThumbnailWorker(const QString &path,
int row,
+ ThumbnailCache::Size size,
std::atomic_bool *cancelFlag)
: m_path(path),
m_row(row),
+ m_size(size),
m_cancelFlag(cancelFlag)
{
setAutoDelete(true);
@@ -17,15 +37,32 @@ void ThumbnailWorker::run()
if (*m_cancelFlag)
return;
+ // Per the spec, never thumbnail files that live inside the cache itself.
+ if (ThumbnailCache::isInsideCache(m_path))
+ return;
+
+ const int target = static_cast<int>(m_size);
+
+ QImage cached = ThumbnailCache::load(m_path, m_size);
+ if (*m_cancelFlag)
+ return;
+
+ if (!cached.isNull())
+ {
+ emit finished(m_row, downscaleForDisplay(cached));
+ return;
+ }
+
QImageReader reader(m_path);
reader.setAutoTransform(true);
// Scale during decoding to avoid hitting the allocation limit
// on very large images (Qt 6 defaults to 128 MB).
QSize fullSize = reader.size();
- if (fullSize.isValid()) {
- QSize target = fullSize.scaled(128, 128, Qt::KeepAspectRatio);
- reader.setScaledSize(target);
+ if (fullSize.isValid())
+ {
+ QSize scaled = fullSize.scaled(target, target, Qt::KeepAspectRatio);
+ reader.setScaledSize(scaled);
}
QImage image = reader.read();
@@ -33,21 +70,23 @@ void ThumbnailWorker::run()
if (*m_cancelFlag)
return;
- if (!image.isNull())
- {
- // Scale to fit within 128x128 keeping aspect ratio
- // (may already be the right size if setScaledSize succeeded)
- QImage scaled = image.scaled(128, 128,
- Qt::KeepAspectRatio,
- Qt::SmoothTransformation);
- // Center on a 128x128 canvas with transparent/white fill
- QImage thumb(128, 128, QImage::Format_ARGB32_Premultiplied);
- thumb.fill(Qt::transparent);
- int x = (128 - scaled.width()) / 2;
- int y = (128 - scaled.height()) / 2;
- QPainter painter(&thumb);
- painter.drawImage(x, y, scaled);
- painter.end();
- emit finished(m_row, thumb);
- }
+ if (image.isNull())
+ return;
+
+ // Ensure the longest side is exactly the target (the decoder hint may have
+ // been ignored for some formats). Aspect ratio preserved, no padding canvas.
+ QImage thumb;
+ if (image.width() > target || image.height() > target)
+ thumb = image.scaled(target, target,
+ Qt::KeepAspectRatio,
+ Qt::SmoothTransformation);
+ else
+ thumb = image;
+
+ ThumbnailCache::save(m_path, m_size, thumb);
+
+ if (*m_cancelFlag)
+ return;
+
+ emit finished(m_row, downscaleForDisplay(thumb));
}
diff --git a/src/thumbnailworker.h b/src/thumbnailworker.h
index 5cdb7cd..c481763 100644
--- a/src/thumbnailworker.h
+++ b/src/thumbnailworker.h
@@ -1,5 +1,7 @@
#pragma once
+#include "thumbnailcache.h"
+
#include <QRunnable>
#include <QObject>
#include <QImage>
@@ -12,6 +14,7 @@ class ThumbnailWorker : public QObject, public QRunnable
public:
ThumbnailWorker(const QString &path,
int row,
+ ThumbnailCache::Size size,
std::atomic_bool *cancelFlag);
void run() override;
@@ -22,5 +25,6 @@ signals:
private:
QString m_path;
int m_row;
+ ThumbnailCache::Size m_size;
std::atomic_bool *m_cancelFlag;
};