git repos / SagoImageBrowser

commit 9472b514

Poul Sander · 2026-07-14 18:50
9472b514988bc6801e7e7679e2b273bd9d488568 patch · browse files
parent 3a4116ab6efe8e6ce169c72ea9dbc715131e4cdb

Make the windows version work by adding a nativepath lib that handles it.

Changed files

M CMakeLists.txt before
M src/exifreader.cpp before
M src/fsdirmodel.cpp before
M src/fsdirmodel.h before
M src/imagemodel.cpp before
M src/imagemodel.h before
M src/imageviewwidget.cpp before
M src/mainwindow.cpp before
M src/mainwindow.h before
A src/nativepath.cpp
A src/nativepath.h
M src/pathcompletermodel.cpp before
M src/pathcompletermodel.h before
M src/thumbnailcache.cpp before
M src/thumbnailworker.cpp before
diff --git a/CMakeLists.txt b/CMakeLists.txt index 12a49be..b3f5b13 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt
@@ -8,6 +8,9 @@ include(GNUInstallDirs)
find_package(Qt6 REQUIRED COMPONENTS Widgets LinguistTools)
+# exiv2's exported target lists Iconv::Iconv in its link interface (notably on
+# the MXE/MinGW Windows build), so the imported target must exist before the
+# exiv2 config below is consumed — even though we never call iconv directly.
find_package(Iconv REQUIRED)
# Use the system-provided exiv2 (both 0.27 and 0.28 are supported)
@@ -40,6 +43,8 @@ qt_add_executable(SagoImageBrowser
src/shortcutmanager.h
src/exifreader.cpp
src/exifreader.h
+ src/nativepath.cpp
+ src/nativepath.h
src/pathcompletermodel.cpp
src/pathcompletermodel.h
src/thumbnaildelegate.cpp
@@ -50,7 +55,7 @@ qt_add_resources(SagoImageBrowser "icons" PREFIX "/icons" FILES extra/icons/sago
target_link_libraries(SagoImageBrowser PRIVATE Qt6::Widgets ${EXIV2_TARGET})
-add_executable(test_exifreader test/test_exifreader.cpp src/exifreader.cpp)
+add_executable(test_exifreader test/test_exifreader.cpp src/exifreader.cpp src/nativepath.cpp)
target_include_directories(test_exifreader PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_link_libraries(test_exifreader PRIVATE Qt6::Widgets ${EXIV2_TARGET})
diff --git a/src/exifreader.cpp b/src/exifreader.cpp index 085f678..0fa0024 100644 --- a/src/exifreader.cpp +++ b/src/exifreader.cpp
@@ -25,6 +25,7 @@ SOFTWARE.
#include "exifreader.h"
+#include "nativepath.h"
#include <QFile>
#include <QImageReader>
@@ -39,10 +40,16 @@ using Exiv2ImagePtr = Exiv2::Image::UniquePtr;
using Exiv2ImagePtr = Exiv2::Image::AutoPtr;
#endif
-#include <sys/stat.h>
-#include <fcntl.h>
-#include <filesystem>
-#include <unistd.h>
+// exiv2 cannot represent an arbitrary Windows name through a narrow std::string;
+// on Windows use its wide-path overload fed from our lossless WTF-8 handle.
+static Exiv2ImagePtr openExiv2(const QByteArray &path)
+{
+#ifdef _WIN32
+ return Exiv2::ImageFactory::open(nativepath::wideFromNative(path));
+#else
+ return Exiv2::ImageFactory::open(path.toStdString());
+#endif
+}
// Raw EXIF date format is "YYYY:MM:DD HH:MM:SS"; reformat date separators to dashes
static QString formatDate(const QString &raw)
@@ -153,21 +160,20 @@ ExifData ExifReader::read(const QByteArray &path)
{
ExifData data;
- struct ::stat st{};
- if (::stat(path.constData(), &st) != 0 || !S_ISREG(st.st_mode))
+ const nativepath::NativeStat st = nativepath::nativeStat(path);
+ if (!st.isRegular)
return data;
// Filename
{
- const std::filesystem::path fsPath(path.toStdString());
- const std::string nativeName = fsPath.filename().native();
- data.filename = QString::fromLocal8Bit(
- nativeName.data(), static_cast<qsizetype>(nativeName.size()));
+ const QByteArray nativeName =
+ nativepath::nativeFromPath(nativepath::pathFromNative(path).filename());
+ data.filename = nativepath::displayFromNative(nativeName);
}
// File size
{
- qint64 size = static_cast<qint64>(st.st_size);
+ qint64 size = st.size;
if (size < 1024)
data.fileSize = QString::number(size) + QLatin1String(" B");
else if (size < 1024 * 1024)
@@ -178,25 +184,20 @@ ExifData ExifReader::read(const QByteArray &path)
// Image dimensions via Qt (reads only the header, no full decode needed)
{
- int fd = ::open(path.constData(), O_RDONLY | O_CLOEXEC);
- if (fd >= 0) {
- QFile file;
- if (file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
- QImageReader reader(&file);
- reader.setDecideFormatFromContent(true);
- QSize sz = reader.size();
- if (sz.isValid())
- data.dimensions = QString::fromLatin1("%1 * %2")
- .arg(sz.width()).arg(sz.height());
- } else {
- ::close(fd);
- }
+ QFile file;
+ if (nativepath::openNativeRead(path, file)) {
+ QImageReader reader(&file);
+ reader.setDecideFormatFromContent(true);
+ QSize sz = reader.size();
+ if (sz.isValid())
+ data.dimensions = QString::fromLatin1("%1 * %2")
+ .arg(sz.width()).arg(sz.height());
}
}
// EXIF tags via exiv2
try {
- Exiv2ImagePtr image = Exiv2::ImageFactory::open(path.toStdString());
+ Exiv2ImagePtr image = openExiv2(path);
image->readMetadata();
const Exiv2::ExifData &exif = image->exifData();
@@ -252,7 +253,7 @@ bool ExifReader::saveCaption(const QByteArray &path,
const ExifData &oldData)
{
try {
- Exiv2ImagePtr image = Exiv2::ImageFactory::open(path.toStdString());
+ Exiv2ImagePtr image = openExiv2(path);
image->readMetadata();
// Erase all existing Caption-Abstract entries then add the new one
@@ -284,7 +285,7 @@ bool ExifReader::saveCaption(const QByteArray &path,
bool ExifReader::rotate(const QByteArray &path, bool clockwise)
{
try {
- Exiv2ImagePtr image = Exiv2::ImageFactory::open(path.toStdString());
+ Exiv2ImagePtr image = openExiv2(path);
image->readMetadata();
Exiv2::ExifData &exif = image->exifData();
diff --git a/src/fsdirmodel.cpp b/src/fsdirmodel.cpp index 7752414..685b6b4 100644 --- a/src/fsdirmodel.cpp +++ b/src/fsdirmodel.cpp
@@ -24,6 +24,7 @@ SOFTWARE.
*/
#include "fsdirmodel.h"
+#include "nativepath.h"
#include <QFileIconProvider>
@@ -41,19 +42,19 @@ QList<FsDirModel::Node *> FsDirModel::listSubdirs(Node *parentNode)
{
QList<Node *> result;
try {
- std::vector<std::pair<std::string, std::string>> entries; // {nativePath, filename}
+ std::vector<std::pair<QByteArray, QByteArray>> entries; // {nativePath, filename}
for (const fs::directory_entry &entry :
- fs::directory_iterator(parentNode->nativePath.toStdString(),
+ fs::directory_iterator(nativepath::pathFromNative(parentNode->nativePath),
fs::directory_options::skip_permission_denied))
{
std::error_code ec;
- const std::string fname = entry.path().filename().native();
- if (fname.empty() || fname[0] == '.')
+ const QByteArray fname = nativepath::nativeFromPath(entry.path().filename());
+ if (fname.isEmpty() || fname.at(0) == '.')
continue;
if (!entry.is_directory(ec))
continue;
- entries.push_back({entry.path().native(), fname});
+ entries.push_back({nativepath::nativeFromPath(entry.path()), fname});
}
std::sort(entries.begin(), entries.end(),
@@ -62,9 +63,8 @@ QList<FsDirModel::Node *> FsDirModel::listSubdirs(Node *parentNode)
result.reserve(static_cast<qsizetype>(entries.size()));
for (const auto &[npath, fname] : entries) {
auto *child = new Node;
- child->nativePath = QByteArray::fromStdString(npath);
- child->displayName = QString::fromLocal8Bit(fname.c_str(),
- static_cast<qsizetype>(fname.size()));
+ child->nativePath = npath;
+ child->displayName = nativepath::displayFromNative(fname);
child->parent = parentNode;
result.append(child);
}
@@ -107,7 +107,7 @@ FsDirModel::~FsDirModel()
void FsDirModel::setRootPath(const QString &path)
{
- setRootPath(QFile::encodeName(path));
+ setRootPath(nativepath::nativeFromDisplay(path));
}
void FsDirModel::setRootPath(const QByteArray &nativePath)
@@ -119,10 +119,11 @@ void FsDirModel::setRootPath(const QByteArray &nativePath)
auto *node = new Node;
node->nativePath = nativePath;
// Display name = last path component (empty for "/", use "/" in that case)
- std::string fname = fs::path(nativePath.toStdString()).filename().native();
- node->displayName = fname.empty()
- ? QFile::decodeName(nativePath)
- : QString::fromLocal8Bit(fname.c_str(), static_cast<qsizetype>(fname.size()));
+ const QByteArray fname =
+ nativepath::nativeFromPath(nativepath::pathFromNative(nativePath).filename());
+ node->displayName = fname.isEmpty()
+ ? nativepath::displayFromNative(nativePath)
+ : nativepath::displayFromNative(fname);
node->parent = &m_virtualRoot;
m_virtualRoot.children.append(node);
}
@@ -149,8 +150,9 @@ QModelIndex FsDirModel::indexForPath(const QByteArray &nativePath)
if (nativePath.isEmpty() || m_virtualRoot.children.isEmpty())
return {};
- const fs::path target(nativePath.toStdString());
- const fs::path root(m_virtualRoot.children.first()->nativePath.toStdString());
+ const fs::path target = nativepath::pathFromNative(nativePath);
+ const fs::path root =
+ nativepath::pathFromNative(m_virtualRoot.children.first()->nativePath);
// Collect the ancestry chain from root down to target
std::vector<fs::path> chain;
@@ -184,10 +186,10 @@ QModelIndex FsDirModel::indexForPath(const QByteArray &nativePath)
}
// Find the child whose nativePath matches step
- const std::string stepStr = step.native();
+ const QByteArray stepBytes = nativepath::nativeFromPath(step);
bool found = false;
for (int i = 0; i < node->children.size(); ++i) {
- if (node->children[i]->nativePath.toStdString() == stepStr) {
+ if (node->children[i]->nativePath == stepBytes) {
node = node->children[i];
idx = createIndex(i, 0, node);
found = true;
diff --git a/src/fsdirmodel.h b/src/fsdirmodel.h index 361c4e7..c9c039e 100644 --- a/src/fsdirmodel.h +++ b/src/fsdirmodel.h
@@ -49,7 +49,7 @@ public:
// and pass the result to QTreeView::setRootIndex() so the root node itself
// is hidden and only its children are displayed as top-level items.
void setRootPath(const QByteArray &nativePath);
- void setRootPath(const QString &path); // convenience: converts via QFile::encodeName
+ void setRootPath(const QString &path); // convenience: converts via nativepath::nativeFromDisplay
// Model index of the root-path node (the one whose *children* are shown
// as top-level items when passed to QTreeView::setRootIndex).
diff --git a/src/imagemodel.cpp b/src/imagemodel.cpp index 41ea26a..e238ffc 100644 --- a/src/imagemodel.cpp +++ b/src/imagemodel.cpp
@@ -25,6 +25,7 @@ SOFTWARE.
#include "imagemodel.h"
#include "thumbnailworker.h"
+#include "nativepath.h"
#include <QImageReader>
#include <QFileIconProvider>
@@ -96,7 +97,7 @@ void ImageModel::setThumbnailSize(ThumbnailCache::Size size)
void ImageModel::setDirectory(const QString &path)
{
- setDirectory(QFile::encodeName(path));
+ setDirectory(nativepath::nativeFromDisplay(path));
}
void ImageModel::setDirectory(const QByteArray &path)
@@ -136,18 +137,18 @@ void ImageModel::setDirectory(const QByteArray &path)
// skipped or mangled as they would be with QDir::entryInfoList().
namespace fs = std::filesystem;
- std::vector<std::string> dirs;
- std::vector<std::string> imageFiles;
+ std::vector<QByteArray> dirs;
+ std::vector<QByteArray> imageFiles;
try {
- fs::path dirPath(path.toStdString());
+ fs::path dirPath = nativepath::pathFromNative(path);
// Add ".." parent entry directly with the literal display name ".."
// (mirrors QDir::NoDot behaviour)
fs::path parentPath = dirPath.parent_path();
if (!parentPath.empty() && parentPath != dirPath) {
QPixmap folderPixmap = m_folderIcon.pixmap(128, 128);
- m_items.append({QByteArray::fromStdString(parentPath.native()),
+ m_items.append({nativepath::nativeFromPath(parentPath),
QStringLiteral(".."), folderPixmap, true, true});
}
@@ -156,46 +157,45 @@ void ImageModel::setDirectory(const QByteArray &path)
fs::directory_options::skip_permission_denied))
{
std::error_code ec;
- const std::string fname = entry.path().filename().native();
- if (fname.empty() || fname[0] == '.')
+ const QByteArray fname = nativepath::nativeFromPath(entry.path().filename());
+ if (fname.isEmpty() || fname.at(0) == '.')
continue; // skip hidden entries
if (entry.is_directory(ec)) {
- dirs.push_back(entry.path().native());
+ dirs.push_back(nativepath::nativeFromPath(entry.path()));
} else if (entry.is_regular_file(ec)) {
// Filter by image extension
- const std::string &nativeExt = entry.path().extension().native();
- if (nativeExt.empty())
+ const QByteArray nativeExt = nativepath::nativeFromPath(entry.path().extension());
+ if (nativeExt.isEmpty())
continue;
// extension() includes the dot; strip it and lower-case
- QByteArray ext = QByteArray(nativeExt.c_str() + 1,
- static_cast<qsizetype>(nativeExt.size()) - 1).toLower();
+ QByteArray ext = nativeExt.mid(1).toLower();
if (imageExts.contains(ext))
- imageFiles.push_back(entry.path().native());
+ imageFiles.push_back(nativepath::nativeFromPath(entry.path()));
}
}
} catch (const fs::filesystem_error &) {
// Directory unreadable — show empty listing
}
- // Sort subdirectories by filename
+ // Sort subdirectories and image files by filename
std::sort(dirs.begin(), dirs.end(),
- [](const auto &a, const auto &b) {
- return fs::path(a).filename() < fs::path(b).filename();
+ [](const QByteArray &a, const QByteArray &b) {
+ return nativepath::pathFromNative(a).filename()
+ < nativepath::pathFromNative(b).filename();
});
-
std::sort(imageFiles.begin(), imageFiles.end(),
- [](const auto &a, const auto &b) {
- return fs::path(a).filename() < fs::path(b).filename();
+ [](const QByteArray &a, const QByteArray &b) {
+ return nativepath::pathFromNative(a).filename()
+ < nativepath::pathFromNative(b).filename();
});
QPixmap folderPixmap = m_folderIcon.pixmap(128, 128);
- for (const std::string &nativePath : dirs)
+ for (const QByteArray &entryPath : dirs)
{
- QByteArray entryPath = QByteArray::fromStdString(nativePath);
- std::string fname = fs::path(nativePath).filename().native();
- QString displayName = QString::fromLocal8Bit(fname.c_str(),
- static_cast<qsizetype>(fname.size()));
+ const QByteArray fname =
+ nativepath::nativeFromPath(nativepath::pathFromNative(entryPath).filename());
+ QString displayName = nativepath::displayFromNative(fname);
m_items.append({entryPath, displayName, folderPixmap, true, true});
}
@@ -204,12 +204,11 @@ void ImageModel::setDirectory(const QByteArray &path)
? &m_folderThumbnailCache[path]
: nullptr;
- for (const std::string &nativePath : imageFiles)
+ for (const QByteArray &entryPath : imageFiles)
{
- QByteArray entryPath = QByteArray::fromStdString(nativePath);
- std::string fname = fs::path(nativePath).filename().native();
- QString displayName = QString::fromLocal8Bit(fname.c_str(),
- static_cast<qsizetype>(fname.size()));
+ const QByteArray fname =
+ nativepath::nativeFromPath(nativepath::pathFromNative(entryPath).filename());
+ QString displayName = nativepath::displayFromNative(fname);
QPixmap thumb = m_placeholder;
bool loaded = false;
diff --git a/src/imagemodel.h b/src/imagemodel.h index 60bd51c..6534de2 100644 --- a/src/imagemodel.h +++ b/src/imagemodel.h
@@ -50,7 +50,7 @@ public:
// path is the raw native OS byte string (handles non-UTF-8 filenames)
void setDirectory(const QByteArray &path);
- void setDirectory(const QString &path); // convenience: converts via QFile::encodeName
+ void setDirectory(const QString &path); // convenience: converts via nativepath::nativeFromDisplay
QByteArray filePath(const QModelIndex &index) const;
bool isFolder(const QModelIndex &index) const;
QByteArray currentDirectory() const;
diff --git a/src/imageviewwidget.cpp b/src/imageviewwidget.cpp index 59686d2..2bd7bdc 100644 --- a/src/imageviewwidget.cpp +++ b/src/imageviewwidget.cpp
@@ -25,6 +25,7 @@ SOFTWARE.
#include "imageviewwidget.h"
#include "shortcutmanager.h"
+#include "nativepath.h"
#include <QPainter>
#include <QMouseEvent>
@@ -36,9 +37,6 @@ SOFTWARE.
#include <algorithm>
#include <iostream>
-#include <fcntl.h>
-#include <unistd.h>
-
// --- ImageLoadWorker ---
ImageLoadWorker::ImageLoadWorker(const QByteArray &path, QObject *parent)
@@ -54,15 +52,8 @@ ImageLoadWorker::ImageLoadWorker(const QByteArray &path, QObject *parent)
// affinity), so onImageLoaded() always executes on the main thread.
void ImageLoadWorker::run()
{
- int fd = ::open(m_path.constData(), O_RDONLY | O_CLOEXEC);
- if (fd < 0) {
- emit imageLoaded(m_path, QPixmap{});
- return;
- }
-
QFile file;
- if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
- ::close(fd);
+ if (!nativepath::openNativeRead(m_path, file)) {
emit imageLoaded(m_path, QPixmap{});
return;
}
@@ -97,15 +88,9 @@ ImageViewWidget::ImageViewWidget(QWidget *parent)
QPixmap ImageViewWidget::loadImageFromDisk(const QByteArray &path)
{
- int fd = ::open(path.constData(), O_RDONLY | O_CLOEXEC);
- if (fd < 0)
- return {};
-
QFile file;
- if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
- ::close(fd);
+ if (!nativepath::openNativeRead(path, file))
return {};
- }
QImageReader reader(&file);
reader.setAutoTransform(true);
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 1dc4c1f..c1e7f7a 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp
@@ -69,10 +69,9 @@ SOFTWARE.
#include <QVBoxLayout>
#include <iostream>
-#include <fcntl.h>
#include <filesystem>
-#include <sys/stat.h>
-#include <unistd.h>
+
+#include "nativepath.h"
namespace
{
@@ -84,15 +83,14 @@ ThumbnailCache::Size thumbnailSizeFromString(const QString &s)
QString displayPath(const QByteArray &path)
{
- return QString::fromLocal8Bit(path.constData(), path.size());
+ return nativepath::displayFromNative(path);
}
QString filenameFromPath(const QByteArray &path)
{
- const std::filesystem::path fsPath(path.toStdString());
- const std::string nativeName = fsPath.filename().native();
- return QString::fromLocal8Bit(nativeName.data(),
- static_cast<qsizetype>(nativeName.size()));
+ const QByteArray nativeName =
+ nativepath::nativeFromPath(nativepath::pathFromNative(path).filename());
+ return nativepath::displayFromNative(nativeName);
}
} // namespace
@@ -183,7 +181,7 @@ void MainWindow::setupUi()
setCentralWidget(m_stack);
- updatePathField(QFile::encodeName(QDir::homePath()));
+ updatePathField(nativepath::nativeFromDisplay(QDir::homePath()));
// Dock widgets — can be dragged, floated and stacked by the user
m_rootDock = new QDockWidget(tr("Roots"), this);
@@ -240,7 +238,7 @@ void MainWindow::setupMenuBar()
void MainWindow::navigateToFolder(const QString &path)
{
- navigateToFolder(QFile::encodeName(path));
+ navigateToFolder(nativepath::nativeFromDisplay(path));
}
QByteArray MainWindow::siblingFolder(int delta) const
@@ -250,12 +248,12 @@ QByteArray MainWindow::siblingFolder(int delta) const
return {};
namespace fs = std::filesystem;
- fs::path current(currentDir.toStdString());
+ fs::path current = nativepath::pathFromNative(currentDir);
fs::path parent = current.parent_path();
if (parent.empty() || parent == current)
return {};
- QByteArray parentPath = QByteArray::fromStdString(parent.native());
+ QByteArray parentPath = nativepath::nativeFromPath(parent);
QModelIndex parentIdx = m_dirModel->indexForPath(parentPath);
if (!parentIdx.isValid())
return {};
@@ -313,7 +311,7 @@ void MainWindow::updatePathField(const QByteArray &path)
{
if (!m_pathEdit)
return;
- const QString text = QFile::decodeName(path);
+ const QString text = nativepath::displayFromNative(path);
if (m_pathEdit->text() != text)
m_pathEdit->setText(text);
}
@@ -333,13 +331,13 @@ void MainWindow::openPath(const QString &path)
return;
if (info.isDir()) {
- navigateToFolder(QFile::encodeName(info.absoluteFilePath()));
+ navigateToFolder(nativepath::nativeFromDisplay(info.absoluteFilePath()));
} else if (info.isFile()) {
- navigateToFolder(QFile::encodeName(info.absolutePath()));
+ navigateToFolder(nativepath::nativeFromDisplay(info.absolutePath()));
// ImageModel::setDirectory is synchronous, so items are ready now.
// Find the file in the model and open it.
- QByteArray target = QFile::encodeName(info.absoluteFilePath());
+ QByteArray target = nativepath::nativeFromDisplay(info.absoluteFilePath());
for (int i = 0; i < m_imageModel->rowCount(); ++i) {
QModelIndex idx = m_imageModel->index(i);
if (m_imageModel->filePath(idx) == target) {
@@ -394,7 +392,7 @@ void MainWindow::setupConnections()
QString rootPath = current->data(Qt::UserRole).toString();
m_dirModel->setRootPath(rootPath);
m_treeView->setRootIndex(m_dirModel->rootIndex());
- navigateToFolder(QFile::encodeName(rootPath));
+ navigateToFolder(nativepath::nativeFromDisplay(rootPath));
});
connect(m_treeView, &QTreeView::clicked, this,
@@ -537,24 +535,21 @@ void MainWindow::showPreview(const QModelIndex &index)
QByteArray path = m_imageModel->filePath(index);
- int fd = ::open(path.constData(), O_RDONLY | O_CLOEXEC);
- if (fd >= 0) {
- QFile file;
- if (file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
- QImageReader reader(&file);
- reader.setAutoTransform(true);
- QSize fullSize = reader.size();
- if (fullSize.isValid()) {
- QSize target = fullSize.scaled(m_previewLabel->size(), Qt::KeepAspectRatio);
- reader.setScaledSize(target);
- }
- QImage image = reader.read();
- m_previewLabel->setPixmap(
- QPixmap::fromImage(image).scaled(
- m_previewLabel->size(),
- Qt::KeepAspectRatio,
- Qt::SmoothTransformation));
+ QFile file;
+ if (nativepath::openNativeRead(path, file)) {
+ QImageReader reader(&file);
+ reader.setAutoTransform(true);
+ QSize fullSize = reader.size();
+ if (fullSize.isValid()) {
+ QSize target = fullSize.scaled(m_previewLabel->size(), Qt::KeepAspectRatio);
+ reader.setScaledSize(target);
}
+ QImage image = reader.read();
+ m_previewLabel->setPixmap(
+ QPixmap::fromImage(image).scaled(
+ m_previewLabel->size(),
+ Qt::KeepAspectRatio,
+ Qt::SmoothTransformation));
}
updateExifInfo(path);
@@ -563,8 +558,8 @@ void MainWindow::showPreview(const QModelIndex &index)
void MainWindow::updateExifInfo(const QByteArray &path)
{
// Check if this is a folder by attempting stat
- struct ::stat st{};
- if (::stat(path.constData(), &st) != 0 || S_ISDIR(st.st_mode)) {
+ const nativepath::NativeStat st = nativepath::nativeStat(path);
+ if (!st.exists || st.isDir) {
m_exifTable->setRowCount(0);
return;
}
@@ -633,7 +628,7 @@ void MainWindow::onEditCaption()
if (path.isEmpty())
return;
- if (::access(path.constData(), W_OK) != 0) {
+ if (!nativepath::isWritable(path)) {
QMessageBox::warning(this, tr("Cannot Edit Caption"),
tr("The file is write-protected and cannot be edited.\n\n%1")
.arg(displayPath(path)));
@@ -698,7 +693,7 @@ void MainWindow::executeContextMenu(const QPoint &globalPos, const QByteArray &p
QAction *copyFullPathAction = menu.addAction(tr("Copy full path"));
menu.addSeparator();
- QFileInfo info(QFile::decodeName(path));
+ QFileInfo info(nativepath::displayFromNative(path));
QString suffix = info.suffix().toLower();
bool isJpg = (suffix == "jpg" || suffix == "jpeg");
@@ -738,7 +733,7 @@ void MainWindow::rotateImage(const QModelIndex &index, bool clockwise)
const QByteArray path = m_imageModel->filePath(index);
- if (::access(path.constData(), W_OK) != 0) {
+ if (!nativepath::isWritable(path)) {
QMessageBox::warning(this, tr("Cannot Rotate Image"),
tr("The file is write-protected and cannot be rotated.\n\n%1")
.arg(displayPath(path)));
diff --git a/src/mainwindow.h b/src/mainwindow.h index 6b784f6..5e92e39 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h
@@ -63,7 +63,7 @@ private:
void onPreferencesTriggered();
void resetLayoutToDefault();
void navigateToFolder(const QByteArray &path);
- void navigateToFolder(const QString &path); // convenience: converts via QFile::encodeName
+ void navigateToFolder(const QString &path); // convenience: converts via nativepath::nativeFromDisplay
void selectPreviousFolderIfExists();
void setFullScreenMode(bool fullScreen);
void loadVisibleThumbnails();
diff --git a/src/nativepath.cpp b/src/nativepath.cpp new file mode 100644 index 0000000..783815e --- /dev/null +++ b/src/nativepath.cpp
@@ -0,0 +1,256 @@
+/*
+MIT License
+
+Copyright (c) 2026 Poul Sander
+
+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.
+
+*/
+
+#include "nativepath.h"
+
+#include <QFile>
+
+#ifdef _WIN32
+#include <io.h>
+#include <sys/stat.h>
+#include <wchar.h>
+#else
+#include <fcntl.h>
+#include <sys/stat.h>
+#include <unistd.h>
+#endif
+
+namespace nativepath {
+
+#ifdef _WIN32
+
+namespace {
+
+constexpr char32_t kReplacement = 0xFFFD;
+constexpr char32_t kHighSurrogateLo = 0xD800;
+constexpr char32_t kHighSurrogateHi = 0xDBFF;
+constexpr char32_t kLowSurrogateLo = 0xDC00;
+constexpr char32_t kLowSurrogateHi = 0xDFFF;
+constexpr char32_t kSupplementary = 0x10000;
+
+// Append a code point as (W)TF-8. Surrogate code points (0xD800-0xDFFF) are
+// encoded as 3 bytes rather than rejected — this is what makes it WTF-8.
+void appendUtf8(QByteArray &out, char32_t cp)
+{
+ if (cp < 0x80) {
+ out.append(static_cast<char>(cp));
+ } else if (cp < 0x800) {
+ out.append(static_cast<char>(0xC0 | (cp >> 6)));
+ out.append(static_cast<char>(0x80 | (cp & 0x3F)));
+ } else if (cp < kSupplementary) {
+ out.append(static_cast<char>(0xE0 | (cp >> 12)));
+ out.append(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
+ out.append(static_cast<char>(0x80 | (cp & 0x3F)));
+ } else {
+ out.append(static_cast<char>(0xF0 | (cp >> 18)));
+ out.append(static_cast<char>(0x80 | ((cp >> 12) & 0x3F)));
+ out.append(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
+ out.append(static_cast<char>(0x80 | (cp & 0x3F)));
+ }
+}
+
+} // namespace
+
+std::wstring wideFromNative(const QByteArray &nativePath)
+{
+ std::wstring out;
+ out.reserve(static_cast<size_t>(nativePath.size()));
+
+ const unsigned char *p =
+ reinterpret_cast<const unsigned char *>(nativePath.constData());
+ const qsizetype n = nativePath.size();
+
+ qsizetype i = 0;
+ while (i < n) {
+ const unsigned char b0 = p[i];
+ char32_t cp;
+ qsizetype len;
+ if (b0 < 0x80) { cp = b0; len = 1; }
+ else if ((b0 & 0xE0) == 0xC0) { cp = b0 & 0x1F; len = 2; }
+ else if ((b0 & 0xF0) == 0xE0) { cp = b0 & 0x0F; len = 3; }
+ else if ((b0 & 0xF8) == 0xF0) { cp = b0 & 0x07; len = 4; }
+ else {
+ out.push_back(static_cast<wchar_t>(kReplacement));
+ ++i;
+ continue;
+ }
+
+ if (i + len > n) {
+ out.push_back(static_cast<wchar_t>(kReplacement));
+ ++i;
+ continue;
+ }
+
+ bool ok = true;
+ for (qsizetype k = 1; k < len; ++k) {
+ const unsigned char bk = p[i + k];
+ if ((bk & 0xC0) != 0x80) { ok = false; break; }
+ cp = (cp << 6) | (bk & 0x3F);
+ }
+ if (!ok) {
+ out.push_back(static_cast<wchar_t>(kReplacement));
+ ++i;
+ continue;
+ }
+
+ i += len;
+ if (cp < kSupplementary) {
+ out.push_back(static_cast<wchar_t>(cp)); // BMP incl. lone surrogates
+ } else {
+ cp -= kSupplementary;
+ out.push_back(static_cast<wchar_t>(kHighSurrogateLo + (cp >> 10)));
+ out.push_back(static_cast<wchar_t>(kLowSurrogateLo + (cp & 0x3FF)));
+ }
+ }
+ return out;
+}
+
+QByteArray nativeFromWide(const std::wstring &wide)
+{
+ QByteArray out;
+ out.reserve(static_cast<qsizetype>(wide.size()) * 3);
+
+ const size_t n = wide.size();
+ for (size_t i = 0; i < n; ++i) {
+ char32_t cp = static_cast<char16_t>(wide[i]);
+ if (cp >= kHighSurrogateLo && cp <= kHighSurrogateHi && i + 1 < n) {
+ const char16_t next = static_cast<char16_t>(wide[i + 1]);
+ if (next >= kLowSurrogateLo && next <= kLowSurrogateHi) {
+ cp = kSupplementary
+ + ((cp - kHighSurrogateLo) << 10)
+ + (next - kLowSurrogateLo);
+ ++i;
+ }
+ }
+ appendUtf8(out, cp);
+ }
+ return out;
+}
+
+std::filesystem::path pathFromNative(const QByteArray &nativePath)
+{
+ return std::filesystem::path(wideFromNative(nativePath));
+}
+
+QByteArray nativeFromPath(const std::filesystem::path &path)
+{
+ return nativeFromWide(path.native());
+}
+
+QString displayFromNative(const QByteArray &nativePath)
+{
+ const std::wstring w = wideFromNative(nativePath);
+ return QString::fromWCharArray(w.data(), static_cast<qsizetype>(w.size()));
+}
+
+QByteArray nativeFromDisplay(const QString &text)
+{
+ return nativeFromWide(text.toStdWString());
+}
+
+bool openNativeRead(const QByteArray &nativePath, QFile &outFile)
+{
+ const std::wstring w = wideFromNative(nativePath);
+ outFile.setFileName(
+ QString::fromWCharArray(w.data(), static_cast<qsizetype>(w.size())));
+ return outFile.open(QIODevice::ReadOnly);
+}
+
+NativeStat nativeStat(const QByteArray &nativePath)
+{
+ NativeStat info;
+ struct _stat64 st{};
+ if (_wstat64(wideFromNative(nativePath).c_str(), &st) != 0)
+ return info;
+ info.exists = true;
+ info.isRegular = (st.st_mode & _S_IFREG) != 0;
+ info.isDir = (st.st_mode & _S_IFDIR) != 0;
+ info.size = static_cast<qint64>(st.st_size);
+ info.mtime = static_cast<qint64>(st.st_mtime);
+ return info;
+}
+
+bool isWritable(const QByteArray &nativePath)
+{
+ constexpr int kWriteMode = 2; // _waccess: 02 == write permission
+ return _waccess(wideFromNative(nativePath).c_str(), kWriteMode) == 0;
+}
+
+#else // POSIX
+
+std::filesystem::path pathFromNative(const QByteArray &nativePath)
+{
+ return std::filesystem::path(nativePath.toStdString());
+}
+
+QByteArray nativeFromPath(const std::filesystem::path &path)
+{
+ return QByteArray::fromStdString(path.native());
+}
+
+QString displayFromNative(const QByteArray &nativePath)
+{
+ return QString::fromLocal8Bit(nativePath.constData(), nativePath.size());
+}
+
+QByteArray nativeFromDisplay(const QString &text)
+{
+ return QFile::encodeName(text);
+}
+
+bool openNativeRead(const QByteArray &nativePath, QFile &outFile)
+{
+ const int fd = ::open(nativePath.constData(), O_RDONLY | O_CLOEXEC);
+ if (fd < 0)
+ return false;
+ if (!outFile.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
+ ::close(fd);
+ return false;
+ }
+ return true;
+}
+
+NativeStat nativeStat(const QByteArray &nativePath)
+{
+ NativeStat info;
+ struct ::stat st{};
+ if (::stat(nativePath.constData(), &st) != 0)
+ return info;
+ info.exists = true;
+ info.isRegular = S_ISREG(st.st_mode);
+ info.isDir = S_ISDIR(st.st_mode);
+ info.size = static_cast<qint64>(st.st_size);
+ info.mtime = static_cast<qint64>(st.st_mtime);
+ return info;
+}
+
+bool isWritable(const QByteArray &nativePath)
+{
+ return ::access(nativePath.constData(), W_OK) == 0;
+}
+
+#endif
+
+} // namespace nativepath
diff --git a/src/nativepath.h b/src/nativepath.h new file mode 100644 index 0000000..07d4ead --- /dev/null +++ b/src/nativepath.h
@@ -0,0 +1,85 @@
+/*
+MIT License
+
+Copyright (c) 2026 Poul Sander
+
+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.
+
+*/
+
+#pragma once
+
+#include <QByteArray>
+#include <QString>
+
+#include <filesystem>
+#include <string>
+
+class QFile;
+
+// Central conversion layer between the application's canonical path handle
+// (a QByteArray of "native OS bytes") and the platform's filesystem APIs.
+//
+// The rest of the code stores every path as a QByteArray and never inspects
+// its bytes. What those bytes *mean* is platform-defined and lives only here:
+// * POSIX : the raw bytes returned by the OS (may be invalid UTF-8, e.g.
+// Latin-1 names) — passed through unchanged.
+// * Windows: WTF-8 — the UTF-8 generalisation that also encodes unpaired
+// UTF-16 surrogates, so ill-formed-but-valid Windows filenames
+// round-trip losslessly, matching the POSIX "never mangle a name"
+// guarantee.
+//
+// This is the only translation unit that contains platform #ifdefs.
+namespace nativepath {
+
+// QByteArray native path <-> std::filesystem::path (used for enumeration and
+// path decomposition such as filename()/parent_path()/extension()).
+std::filesystem::path pathFromNative(const QByteArray &nativePath);
+QByteArray nativeFromPath(const std::filesystem::path &path);
+
+// Native byte path -> lossy display string for the UI.
+QString displayFromNative(const QByteArray &nativePath);
+
+// Well-formed display/UI string (dialogs, typed input) -> native byte path.
+QByteArray nativeFromDisplay(const QString &text);
+
+// Open a file read-only and adopt it into outFile. Returns true on success.
+// Bypasses Qt's name-based path handling on POSIX (so non-UTF-8 names work);
+// on Windows Qt already opens via the wide API, which is lossless.
+bool openNativeRead(const QByteArray &nativePath, QFile &outFile);
+
+struct NativeStat {
+ bool exists = false;
+ bool isRegular = false;
+ bool isDir = false;
+ qint64 size = 0;
+ qint64 mtime = 0;
+};
+NativeStat nativeStat(const QByteArray &nativePath);
+
+// True if the file exists and is writable by the current process.
+bool isWritable(const QByteArray &nativePath);
+
+#ifdef _WIN32
+// WTF-8 codec. Exposed for the exiv2 wide-path overload and for unit tests.
+std::wstring wideFromNative(const QByteArray &nativePath);
+QByteArray nativeFromWide(const std::wstring &wide);
+#endif
+
+} // namespace nativepath
diff --git a/src/pathcompletermodel.cpp b/src/pathcompletermodel.cpp index 931888d..7a024a9 100644 --- a/src/pathcompletermodel.cpp +++ b/src/pathcompletermodel.cpp
@@ -24,6 +24,7 @@ SOFTWARE.
*/
#include "pathcompletermodel.h"
+#include "nativepath.h"
#include <QFileInfo>
@@ -75,27 +76,26 @@ void PathCompleterModel::setPrefix(const QString &text)
QStringList newEntries;
try {
- const QByteArray parentNative = QFile::encodeName(parentDir);
- std::vector<std::pair<std::string, std::string>> entries; // {nativePath, filename}
+ const QByteArray parentNative = nativepath::nativeFromDisplay(parentDir);
+ std::vector<std::pair<QByteArray, QByteArray>> entries; // {nativePath, filename}
for (const fs::directory_entry &entry :
- fs::directory_iterator(parentNative.toStdString(),
+ fs::directory_iterator(nativepath::pathFromNative(parentNative),
fs::directory_options::skip_permission_denied))
{
std::error_code ec;
- const std::string fname = entry.path().filename().native();
- if (fname.empty() || fname[0] == '.')
+ const QByteArray fname = nativepath::nativeFromPath(entry.path().filename());
+ if (fname.isEmpty() || fname.at(0) == '.')
continue;
if (!entry.is_directory(ec))
continue;
- entries.push_back({entry.path().native(), fname});
+ entries.push_back({nativepath::nativeFromPath(entry.path()), fname});
}
std::sort(entries.begin(), entries.end(),
[](const auto &a, const auto &b) { return a.second < b.second; });
newEntries.reserve(static_cast<qsizetype>(entries.size()));
for (const auto &[npath, fname] : entries) {
- newEntries.append(QFile::decodeName(
- QByteArray::fromStdString(npath)));
+ newEntries.append(nativepath::displayFromNative(npath));
}
} catch (const fs::filesystem_error &) {
// Inaccessible parent — fall through with an empty list.
diff --git a/src/pathcompletermodel.h b/src/pathcompletermodel.h index 49ec973..71639bb 100644 --- a/src/pathcompletermodel.h +++ b/src/pathcompletermodel.h
@@ -35,8 +35,8 @@ SOFTWARE.
// when the parent directory actually changes, so per-keystroke cost is just
// the QCompleter filter pass over an already-loaded list.
//
-// Directory enumeration mirrors FsDirModel::listSubdirs (std::filesystem with
-// QFile::decodeName), so non-ASCII names that round-trip through the locale
+// Directory enumeration mirrors FsDirModel::listSubdirs (std::filesystem via
+// the nativepath helpers), so names the OS's Unicode encoding cannot represent
// are listed correctly.
class PathCompleterModel : public QAbstractListModel
{
diff --git a/src/thumbnailcache.cpp b/src/thumbnailcache.cpp index 1832f06..3f81b64 100644 --- a/src/thumbnailcache.cpp +++ b/src/thumbnailcache.cpp
@@ -24,6 +24,7 @@ SOFTWARE.
*/
#include "thumbnailcache.h"
+#include "nativepath.h"
#include <QCryptographicHash>
#include <QDir>
@@ -32,8 +33,6 @@ SOFTWARE.
#include <QTemporaryFile>
#include <QUrl>
-#include <sys/stat.h>
-
namespace
{
constexpr const char *kSoftware = "SagoImageBrowser";
@@ -89,8 +88,8 @@ bool ThumbnailCache::isInsideCache(const QByteArray &absPath)
// using its arguments. Safe to call concurrently from multiple threads.
QImage ThumbnailCache::load(const QByteArray &sourcePath, Size size)
{
- struct ::stat st{};
- if (::stat(sourcePath.constData(), &st) != 0 || !S_ISREG(st.st_mode))
+ const nativepath::NativeStat st = nativepath::nativeStat(sourcePath);
+ if (!st.isRegular)
return {};
const QByteArray uri = canonicalUri(sourcePath);
@@ -100,7 +99,7 @@ QImage ThumbnailCache::load(const QByteArray &sourcePath, Size size)
if (!img.load(cachePath, "PNG"))
return {};
- const qint64 mtime = static_cast<qint64>(st.st_mtime);
+ const qint64 mtime = st.mtime;
bool ok = false;
const qint64 cachedMTime = img.text("Thumb::MTime").toLongLong(&ok);
if (!ok || cachedMTime != mtime)
@@ -118,8 +117,8 @@ void ThumbnailCache::save(const QByteArray &sourcePath, Size size, const QImage
if (thumbImage.isNull())
return;
- struct ::stat st{};
- if (::stat(sourcePath.constData(), &st) != 0 || !S_ISREG(st.st_mode))
+ const nativepath::NativeStat st = nativepath::nativeStat(sourcePath);
+ if (!st.isRegular)
return;
const QByteArray uri = canonicalUri(sourcePath);
@@ -134,9 +133,8 @@ void ThumbnailCache::save(const QByteArray &sourcePath, Size size, const QImage
QImage tagged = thumbImage;
tagged.setText("Thumb::URI", QString::fromLatin1(uri));
- tagged.setText("Thumb::MTime",
- QString::number(static_cast<qint64>(st.st_mtime)));
- tagged.setText("Thumb::Size", QString::number(static_cast<qint64>(st.st_size)));
+ tagged.setText("Thumb::MTime", QString::number(st.mtime));
+ tagged.setText("Thumb::Size", QString::number(st.size));
tagged.setText("Software", QString::fromLatin1(kSoftware));
QTemporaryFile tmp(subdir + "/tmp_XXXXXX.png");
diff --git a/src/thumbnailworker.cpp b/src/thumbnailworker.cpp index e4863f1..2116ed0 100644 --- a/src/thumbnailworker.cpp +++ b/src/thumbnailworker.cpp
@@ -26,13 +26,11 @@ SOFTWARE.
#include "thumbnailworker.h"
#include "thumbnailcache.h"
+#include "nativepath.h"
#include <QFile>
#include <QImageReader>
-#include <fcntl.h>
-#include <unistd.h>
-
namespace
{
constexpr int kDisplaySize = 128;
@@ -89,18 +87,12 @@ void ThumbnailWorker::run()
return;
}
- // Open the file via POSIX open() so that paths with non-UTF-8 bytes
- // (e.g. Latin-1 encoded filenames) are handled correctly.
- int fd = ::open(m_path.constData(), O_RDONLY | O_CLOEXEC);
- if (fd < 0)
- return;
-
+ // Open via the native-path helper so that names the OS's Unicode encoding
+ // cannot represent (non-UTF-8 bytes on POSIX, unpaired surrogates on
+ // Windows) are handled correctly.
QFile file;
- if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle))
- {
- ::close(fd);
+ if (!nativepath::openNativeRead(m_path, file))
return;
- }
QImageReader reader(&file);
reader.setAutoTransform(true);