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 #include #include @@ -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 m_folderThumbnailCache; QList 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 #include @@ -26,6 +27,15 @@ #include #include +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 +#include +#include +#include +#include +#include +#include +#include + +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 +#include + +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 -#include + +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(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 #include #include @@ -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; };