diff --git a/CMakeLists.txt b/CMakeLists.txt index b332a92..1026ab0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,7 +25,9 @@ qt_add_executable(SagoImageBrowser src/preferencesdialog.cpp src/preferencesdialog.h src/exifreader.cpp - src/exifreader.h) + src/exifreader.h + src/pathcompletermodel.cpp + src/pathcompletermodel.h) target_include_directories(SagoImageBrowser PRIVATE ${EXIV2_INCLUDE_DIRS}) target_link_libraries(SagoImageBrowser PRIVATE Qt6::Widgets ${EXIV2_LIBRARIES}) \ No newline at end of file diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 4275c07..97b6eb5 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -5,6 +5,7 @@ #include "thumbnailcache.h" #include "exifreader.h" #include "fsdirmodel.h" +#include "pathcompletermodel.h" #include #include @@ -31,9 +32,13 @@ #include #include #include +#include #include #include #include +#include +#include +#include #include #include @@ -108,14 +113,38 @@ void MainWindow::setupUi() // Single image view m_imageView = new ImageViewWidget; + // Path bar: editable absolute path with ajax-style folder completion and a Go button + m_pathEdit = new QLineEdit; + m_goButton = new QPushButton(tr("Go")); + m_pathCompleterModel = new PathCompleterModel(this); + m_pathCompleter = new QCompleter(m_pathCompleterModel, this); + m_pathCompleter->setCaseSensitivity(Qt::CaseSensitive); + m_pathCompleter->setCompletionMode(QCompleter::PopupCompletion); + m_pathCompleter->setFilterMode(Qt::MatchStartsWith); + m_pathEdit->setCompleter(m_pathCompleter); + + auto *pathRow = new QHBoxLayout; + pathRow->setContentsMargins(4, 4, 4, 0); + pathRow->addWidget(m_pathEdit, 1); + pathRow->addWidget(m_goButton); + + auto *browserPage = new QWidget; + auto *vbox = new QVBoxLayout(browserPage); + vbox->setContentsMargins(0, 0, 0, 0); + vbox->setSpacing(0); + vbox->addLayout(pathRow); + vbox->addWidget(m_listView, 1); + // Stack to switch between browser and single image m_stack = new QStackedWidget; - m_stack->addWidget(m_listView); + m_stack->addWidget(browserPage); m_stack->addWidget(m_imageView); - m_browserPage = m_listView; + m_browserPage = browserPage; setCentralWidget(m_stack); + updatePathField(QFile::encodeName(QDir::homePath())); + // Dock widgets — can be dragged, floated and stacked by the user m_rootDock = new QDockWidget(tr("Roots"), this); m_rootDock->setObjectName("RootDock"); @@ -181,12 +210,30 @@ void MainWindow::navigateToFolder(const QByteArray &path) QModelIndex dirIdx = m_dirModel->indexForPath(path); if (dirIdx.isValid()) m_treeView->setCurrentIndex(dirIdx); + updatePathField(path); QTimer::singleShot(0, this, [this]() { selectPreviousFolderIfExists(); loadVisibleThumbnails(); }); } +void MainWindow::updatePathField(const QByteArray &path) +{ + if (!m_pathEdit) + return; + const QString text = QFile::decodeName(path); + if (m_pathEdit->text() != text) + m_pathEdit->setText(text); +} + +void MainWindow::onPathEntered() +{ + const QString text = m_pathEdit->text().trimmed(); + if (text.isEmpty()) + return; + openPath(text); +} + void MainWindow::openPath(const QString &path) { QFileInfo info(path); @@ -236,6 +283,13 @@ void MainWindow::selectPreviousFolderIfExists() void MainWindow::setupConnections() { + connect(m_pathEdit, &QLineEdit::returnPressed, + this, &MainWindow::onPathEntered); + connect(m_goButton, &QPushButton::clicked, + this, &MainWindow::onPathEntered); + connect(m_pathEdit, &QLineEdit::textEdited, + m_pathCompleterModel, &PathCompleterModel::setPrefix); + connect(m_rootList, &QListWidget::currentItemChanged, this, [this](QListWidgetItem *current, QListWidgetItem *) { if (current == nullptr) diff --git a/src/mainwindow.h b/src/mainwindow.h index 944db13..8edecb4 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -12,8 +12,12 @@ class QDockWidget; class FsDirModel; class QListWidget; class QTableWidget; +class QLineEdit; +class QPushButton; +class QCompleter; class ImageModel; class ImageViewWidget; +class PathCompleterModel; class PreferencesDialog; class MainWindow : public QMainWindow @@ -42,6 +46,8 @@ private: void enterSingleImageMode(const QModelIndex &index); void leaveSingleImageMode(); void onEditCaption(); + void onPathEntered(); + void updatePathField(const QByteArray &path); QList computeNeighborPaths(const QModelIndex &index) const; void keyPressEvent(QKeyEvent *event) override; @@ -55,6 +61,10 @@ private: QListWidget *m_rootList; QListView *m_listView; QLabel *m_previewLabel; + QLineEdit *m_pathEdit = nullptr; + QPushButton *m_goButton = nullptr; + PathCompleterModel *m_pathCompleterModel = nullptr; + QCompleter *m_pathCompleter = nullptr; QStackedWidget *m_stack; QString m_backgroundColorPreference = "black"; diff --git a/src/pathcompletermodel.cpp b/src/pathcompletermodel.cpp new file mode 100644 index 0000000..2afcf71 --- /dev/null +++ b/src/pathcompletermodel.cpp @@ -0,0 +1,85 @@ +#include "pathcompletermodel.h" + +#include +#include + +#include +#include +#include + +namespace fs = std::filesystem; + +PathCompleterModel::PathCompleterModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int PathCompleterModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return static_cast(m_entries.size()); +} + +QVariant PathCompleterModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) + return {}; + if (role == Qt::DisplayRole || role == Qt::EditRole) + return m_entries.at(index.row()); + return {}; +} + +void PathCompleterModel::setPrefix(const QString &text) +{ + // Determine the directory whose children we should list. + QString parentDir; + if (text.isEmpty()) { + parentDir = QStringLiteral("/"); + } else if (text.endsWith(QLatin1Char('/'))) { + // Strip the trailing slash unless we're at filesystem root. + parentDir = (text.size() == 1) ? text : text.left(text.size() - 1); + } else { + QFileInfo info(text); + parentDir = info.path(); + if (parentDir.isEmpty()) + parentDir = QStringLiteral("/"); + } + + if (m_hasParent && parentDir == m_currentParent) + return; + + QStringList newEntries; + try { + const QByteArray parentNative = QFile::encodeName(parentDir); + std::vector> entries; // {nativePath, filename} + for (const fs::directory_entry &entry : + fs::directory_iterator(parentNative.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; }); + + newEntries.reserve(static_cast(entries.size())); + for (const auto &[npath, fname] : entries) { + newEntries.append(QFile::decodeName( + QByteArray::fromStdString(npath))); + } + } catch (const fs::filesystem_error &) { + // Inaccessible parent — fall through with an empty list. + } + + beginResetModel(); + m_entries = std::move(newEntries); + m_currentParent = parentDir; + m_hasParent = true; + endResetModel(); +} diff --git a/src/pathcompletermodel.h b/src/pathcompletermodel.h new file mode 100644 index 0000000..5dda786 --- /dev/null +++ b/src/pathcompletermodel.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +// Lightweight ajax-style completion model that exposes the immediate +// subdirectories of whatever parent directory the user is typing into the +// path field. Refreshed on each text change via setPrefix(); we only re-scan +// 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 +// are listed correctly. +class PathCompleterModel : public QAbstractListModel +{ + Q_OBJECT + +public: + explicit PathCompleterModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + +public slots: + // Update entries based on the current text in the path field. + void setPrefix(const QString &text); + +private: + QStringList m_entries; + QString m_currentParent; + bool m_hasParent = false; +};