git repos / SagoImageBrowser

commit 40efdfde

Poul Sander · 2026-05-19 21:24
40efdfde67a0b772c31bbbab7fbc9b1ca53e07bd patch · browse files
parent 80a9926d20e6e662214fd2964baf7377b4d9710a

The side panel now also handles non UTF-8 chars

Changed files

M CMakeLists.txt before
A src/fsdirmodel.cpp
A src/fsdirmodel.h
M src/mainwindow.cpp before
M src/mainwindow.h before
diff --git a/CMakeLists.txt b/CMakeLists.txt index a7fdec4..b332a92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt
@@ -14,6 +14,8 @@ qt_add_executable(SagoImageBrowser
src/mainwindow.h
src/imagemodel.cpp
src/imagemodel.h
+ src/fsdirmodel.cpp
+ src/fsdirmodel.h
src/thumbnailworker.cpp
src/thumbnailworker.h
src/thumbnailcache.cpp
diff --git a/src/fsdirmodel.cpp b/src/fsdirmodel.cpp new file mode 100644 index 0000000..a9babb5 --- /dev/null +++ b/src/fsdirmodel.cpp
@@ -0,0 +1,283 @@
+#include "fsdirmodel.h"
+
+#include <QFile>
+#include <QFileIconProvider>
+
+#include <algorithm>
+#include <filesystem>
+#include <vector>
+
+namespace fs = std::filesystem;
+
+// ---------------------------------------------------------------------------
+// Static helpers
+// ---------------------------------------------------------------------------
+
+QList<FsDirModel::Node *> FsDirModel::listSubdirs(Node *parentNode)
+{
+ QList<Node *> result;
+ try {
+ std::vector<std::pair<std::string, std::string>> entries; // {nativePath, filename}
+
+ for (const fs::directory_entry &entry :
+ fs::directory_iterator(parentNode->nativePath.toStdString(),
+ fs::directory_options::skip_permission_denied))
+ {
+ std::error_code ec;
+ const std::string fname = entry.path().filename().native();
+ if (fname.empty() || fname[0] == '.')
+ continue;
+ if (!entry.is_directory(ec))
+ continue;
+ entries.push_back({entry.path().native(), fname});
+ }
+
+ std::sort(entries.begin(), entries.end(),
+ [](const auto &a, const auto &b) { return a.second < b.second; });
+
+ 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->parent = parentNode;
+ result.append(child);
+ }
+ } catch (const fs::filesystem_error &) {}
+
+ return result;
+}
+
+void FsDirModel::destroyChildren(Node *node)
+{
+ for (Node *child : node->children) {
+ destroyChildren(child);
+ delete child;
+ }
+ node->children.clear();
+ node->populated = false;
+}
+
+// ---------------------------------------------------------------------------
+// Construction / destruction
+// ---------------------------------------------------------------------------
+
+FsDirModel::FsDirModel(QObject *parent)
+ : QAbstractItemModel(parent)
+{
+ m_virtualRoot.populated = true; // virtual root is always "populated"
+
+ QFileIconProvider iconProvider;
+ m_folderIcon = iconProvider.icon(QFileIconProvider::Folder);
+}
+
+FsDirModel::~FsDirModel()
+{
+ destroyChildren(&m_virtualRoot);
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+void FsDirModel::setRootPath(const QString &path)
+{
+ setRootPath(QFile::encodeName(path));
+}
+
+void FsDirModel::setRootPath(const QByteArray &nativePath)
+{
+ beginResetModel();
+ destroyChildren(&m_virtualRoot);
+
+ if (!nativePath.isEmpty()) {
+ 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()));
+ node->parent = &m_virtualRoot;
+ m_virtualRoot.children.append(node);
+ }
+
+ endResetModel();
+}
+
+QModelIndex FsDirModel::rootIndex() const
+{
+ if (m_virtualRoot.children.isEmpty())
+ return {};
+ return createIndex(0, 0, m_virtualRoot.children.first());
+}
+
+QByteArray FsDirModel::filePath(const QModelIndex &index) const
+{
+ if (!index.isValid())
+ return {};
+ return nodeForIndex(index)->nativePath;
+}
+
+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());
+
+ // Collect the ancestry chain from root down to target
+ std::vector<fs::path> chain;
+ fs::path cur = target;
+ while (cur != root) {
+ chain.push_back(cur);
+ fs::path up = cur.parent_path();
+ if (up == cur)
+ return {}; // reached filesystem root without finding model root
+ cur = up;
+ }
+ // chain is [target, …, root_child]; reverse so we walk root→target
+ std::reverse(chain.begin(), chain.end());
+
+ // Start at the root path node
+ Node *node = m_virtualRoot.children.first();
+ QModelIndex idx = createIndex(0, 0, node);
+
+ for (const fs::path &step : chain) {
+ // Ensure this node's children are loaded
+ if (!node->populated) {
+ QList<Node *> children = listSubdirs(node);
+ if (!children.isEmpty()) {
+ beginInsertRows(idx, 0, children.size() - 1);
+ node->children = children;
+ node->populated = true;
+ endInsertRows();
+ } else {
+ node->populated = true;
+ }
+ }
+
+ // Find the child whose nativePath matches step
+ const std::string stepStr = step.native();
+ bool found = false;
+ for (int i = 0; i < node->children.size(); ++i) {
+ if (node->children[i]->nativePath.toStdString() == stepStr) {
+ node = node->children[i];
+ idx = createIndex(i, 0, node);
+ found = true;
+ break;
+ }
+ }
+ if (!found)
+ return {};
+ }
+
+ return idx;
+}
+
+// ---------------------------------------------------------------------------
+// QAbstractItemModel interface
+// ---------------------------------------------------------------------------
+
+FsDirModel::Node *FsDirModel::nodeForIndex(const QModelIndex &index) const
+{
+ if (!index.isValid())
+ return const_cast<Node *>(&m_virtualRoot);
+ return static_cast<Node *>(index.internalPointer());
+}
+
+QModelIndex FsDirModel::indexForNode(Node *node) const
+{
+ if (!node || node == &m_virtualRoot)
+ return {};
+ Node *parentNode = node->parent;
+ int row = parentNode->children.indexOf(node);
+ if (row < 0)
+ return {};
+ return createIndex(row, 0, node);
+}
+
+QModelIndex FsDirModel::index(int row, int column, const QModelIndex &parent) const
+{
+ if (column != 0)
+ return {};
+
+ Node *parentNode = nodeForIndex(parent);
+ if (row < 0 || row >= parentNode->children.size())
+ return {};
+
+ return createIndex(row, 0, parentNode->children[row]);
+}
+
+QModelIndex FsDirModel::parent(const QModelIndex &index) const
+{
+ if (!index.isValid())
+ return {};
+
+ Node *node = static_cast<Node *>(index.internalPointer());
+ Node *parentNode = node->parent;
+
+ if (!parentNode || parentNode == &m_virtualRoot)
+ return {};
+
+ return indexForNode(parentNode);
+}
+
+int FsDirModel::rowCount(const QModelIndex &parent) const
+{
+ return nodeForIndex(parent)->children.size();
+}
+
+int FsDirModel::columnCount(const QModelIndex &) const
+{
+ return 1;
+}
+
+QVariant FsDirModel::data(const QModelIndex &index, int role) const
+{
+ if (!index.isValid())
+ return {};
+
+ const Node *node = static_cast<const Node *>(index.internalPointer());
+
+ switch (role) {
+ case Qt::DisplayRole:
+ return node->displayName;
+ case Qt::DecorationRole:
+ return m_folderIcon;
+ default:
+ return {};
+ }
+}
+
+bool FsDirModel::hasChildren(const QModelIndex &parent) const
+{
+ const Node *node = nodeForIndex(parent);
+ if (!node->populated)
+ return true; // optimistic: show expand arrow until we actually look
+ return !node->children.isEmpty();
+}
+
+bool FsDirModel::canFetchMore(const QModelIndex &parent) const
+{
+ return !nodeForIndex(parent)->populated;
+}
+
+void FsDirModel::fetchMore(const QModelIndex &parent)
+{
+ Node *node = nodeForIndex(parent);
+ if (node->populated)
+ return;
+
+ QList<Node *> children = listSubdirs(node);
+ node->populated = true;
+
+ if (children.isEmpty())
+ return;
+
+ beginInsertRows(parent, 0, children.size() - 1);
+ node->children = children;
+ endInsertRows();
+}
diff --git a/src/fsdirmodel.h b/src/fsdirmodel.h new file mode 100644 index 0000000..4c1ff85 --- /dev/null +++ b/src/fsdirmodel.h
@@ -0,0 +1,70 @@
+#pragma once
+
+#include <QAbstractItemModel>
+#include <QByteArray>
+#include <QIcon>
+#include <QList>
+
+// A directory-tree model that uses std::filesystem for enumeration so that
+// directory entries with non-UTF-8 filenames (e.g. Latin-1 encoded names on
+// a UTF-8 locale) are listed correctly instead of being silently dropped or
+// mangled by QDir / QFileSystemModel.
+//
+// The model is lazily populated: child directories are enumerated only when
+// the view first expands a node (via canFetchMore / fetchMore).
+class FsDirModel : public QAbstractItemModel
+{
+ Q_OBJECT
+
+public:
+ explicit FsDirModel(QObject *parent = nullptr);
+ ~FsDirModel() override;
+
+ // Set the directory shown as the tree root. Call rootIndex() afterwards
+ // 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
+
+ // Model index of the root-path node (the one whose *children* are shown
+ // as top-level items when passed to QTreeView::setRootIndex).
+ QModelIndex rootIndex() const;
+
+ // Raw native OS byte path for the node at index.
+ QByteArray filePath(const QModelIndex &index) const;
+
+ // Walk the tree to find (and lazily populate) the node for nativePath.
+ // Returns an invalid index if the path is not under the current root.
+ QModelIndex indexForPath(const QByteArray &nativePath);
+
+ // QAbstractItemModel interface
+ QModelIndex index(int row, int column,
+ const QModelIndex &parent = QModelIndex()) const override;
+ QModelIndex parent(const QModelIndex &index) const override;
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+ int columnCount(const QModelIndex &parent = QModelIndex()) const override;
+ QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+ bool hasChildren(const QModelIndex &parent = QModelIndex()) const override;
+ bool canFetchMore(const QModelIndex &parent) const override;
+ void fetchMore(const QModelIndex &parent) override;
+
+private:
+ struct Node {
+ QByteArray nativePath;
+ QString displayName;
+ Node *parent = nullptr;
+ QList<Node *> children;
+ bool populated = false;
+ };
+
+ // Synchronously populate node->children from the filesystem.
+ // Only call this inside fetchMore (with proper begin/endInsertRows around it).
+ static QList<Node *> listSubdirs(Node *parent);
+
+ Node *nodeForIndex(const QModelIndex &index) const;
+ QModelIndex indexForNode(Node *node) const;
+ static void destroyChildren(Node *node);
+
+ Node m_virtualRoot; // hidden root; always has exactly 0 or 1 child
+ QIcon m_folderIcon;
+};
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 110812f..6559d9d 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp
@@ -4,9 +4,10 @@
#include "preferencesdialog.h"
#include "thumbnailcache.h"
#include "exifreader.h"
+#include "fsdirmodel.h"
#include <QFile>
-#include <QFileSystemModel>
+#include <QTreeView>
#include <QTreeView>
#include <QListView>
#include <QLabel>
@@ -54,9 +55,9 @@ MainWindow::MainWindow(QWidget *parent)
void MainWindow::setupUi()
{
- // Folder model (left pane)
- m_dirModel = new QFileSystemModel(this);
- m_dirModel->setFilter(QDir::AllDirs | QDir::NoDotAndDotDot);
+ // Folder model (left pane) — backed by std::filesystem so directories with
+ // non-UTF-8 filenames are listed correctly.
+ m_dirModel = new FsDirModel(this);
m_dirModel->setRootPath(QDir::homePath());
// Root chooser list
@@ -74,7 +75,7 @@ void MainWindow::setupUi()
m_treeView = new QTreeView;
m_treeView->setModel(m_dirModel);
- m_treeView->setRootIndex(m_dirModel->index(QDir::homePath()));
+ m_treeView->setRootIndex(m_dirModel->rootIndex());
m_treeView->header()->hide();
m_treeView->setColumnHidden(1, true);
m_treeView->setColumnHidden(2, true);
@@ -172,10 +173,7 @@ void MainWindow::navigateToFolder(const QByteArray &path)
{
m_previousFolderPath = m_imageModel->currentDirectory();
m_imageModel->setDirectory(path);
- // The tree view uses QFileSystemModel which works with QString; convert for it.
- // If the path contains non-UTF-8 bytes the index lookup may fail — that is
- // acceptable (the tree view simply won't highlight the node).
- QModelIndex dirIdx = m_dirModel->index(QFile::decodeName(path));
+ QModelIndex dirIdx = m_dirModel->indexForPath(path);
if (dirIdx.isValid())
m_treeView->setCurrentIndex(dirIdx);
QTimer::singleShot(0, this, [this]() {
@@ -240,13 +238,13 @@ void MainWindow::setupConnections()
QString rootPath = current->data(Qt::UserRole).toString();
m_dirModel->setRootPath(rootPath);
- m_treeView->setRootIndex(m_dirModel->index(rootPath));
+ m_treeView->setRootIndex(m_dirModel->rootIndex());
});
connect(m_treeView, &QTreeView::clicked, this,
[this](const QModelIndex &index)
{
- QString path = m_dirModel->filePath(index);
+ QByteArray path = m_dirModel->filePath(index);
navigateToFolder(path);
});
diff --git a/src/mainwindow.h b/src/mainwindow.h index 444b448..52b4511 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h
@@ -9,7 +9,7 @@ class QLabel;
class QStackedWidget;
class QMenuBar;
class QDockWidget;
-class QFileSystemModel;
+class FsDirModel;
class QListWidget;
class QTableWidget;
class ImageModel;
@@ -47,7 +47,7 @@ private:
bool eventFilter(QObject *obj, QEvent *event) override;
void closeEvent(QCloseEvent *event) override;
- QFileSystemModel *m_dirModel;
+ FsDirModel *m_dirModel;
ImageModel *m_imageModel;
QTreeView *m_treeView;