Compare commits

...
13 Commits
Author SHA1 Message Date
yyc12345 e01718928c feat: remove checkerboard icon and change bottom toolbar 2026-07-25 12:23:00 +08:00
yyc12345 f5dbf44ec7 refactor: add possible optimization in graphics view 2026-07-25 12:15:17 +08:00
yyc12345 f55f8c731d refactor: use extensible design for fetching playlist from single file 2026-07-25 12:06:11 +08:00
yyc12345 4ed491c78a refactor: remove closeWindow because no one need it 2026-07-24 15:17:05 +08:00
yyc12345 b54cde2cbf refactor: remove "auto" in window-size behavior
- "auto" is not essential for our hard fork, so i simply remove it.
- remove all useless code after removing it.
- enlarge the min size of window because previous may trigger bad layout when showing navigation view.
2026-07-24 15:14:24 +08:00
yyc12345 b1d12cc406 refactor: remove double-click-window behavior
- remove double-click-window behavior entirely
- remove the icon of copy image action
2026-07-24 15:07:30 +08:00
yyc12345 aa13355966 refactor: use UI file for main window
- main window now use UI file for layout.
- remove all manual computed widget position code. use full Qt layout implementation instead.
- remove useless functions in main window
- change style changed slot as private
2026-07-24 14:57:41 +08:00
yyc12345 bd35a8c3e0 feat: use submenu in right click menu to reduce the height of menu panel 2026-07-15 21:59:30 +08:00
yyc12345 81413b51da feat: support explorer sort and hidden on Window
now playlist can use Windows Explorer's sort and hidden file display options to build it own for better browse experience on Windows
2026-07-14 13:22:23 +08:00
yyc12345 a01d640c27 refactor: remove frameless window
remove frameless window and merge it into mainwindow. because we do not need frameless and transparent window, so most of its functions are removed in previous commits.
2026-07-13 15:59:39 +08:00
yyc12345 16800df138 refactor: remove useless function in main window 2026-07-13 15:50:30 +08:00
yyc12345 7e8fd4ea21 refactor: use class member menu instead of runtime created menu 2026-07-13 15:47:17 +08:00
yyc12345 04fa491cd8 refactor: fully refactor playlist manager
- refactor playlist manager. remove useless qt model design.
- update action updator function.
2026-07-13 15:40:59 +08:00
27 changed files with 1187 additions and 777 deletions
+12 -2
View File
@@ -39,7 +39,6 @@ endif ()
set (PPIC_CPP_FILES
app/main.cpp
app/framelesswindow.cpp
app/mainwindow.cpp
app/actionmanager.cpp
app/graphicsview.cpp
@@ -54,10 +53,12 @@ set (PPIC_CPP_FILES
app/exiv2wrapper.cpp
app/playlistmanager.cpp
app/fileopeneventhandler.cpp
app/playlistscout.cpp
app/playlistscouts/qtscout.cpp
app/playlistscouts/explorerscout.cpp
)
set (PPIC_HEADER_FILES
app/framelesswindow.h
app/mainwindow.h
app/actionmanager.h
app/graphicsview.h
@@ -72,12 +73,16 @@ set (PPIC_HEADER_FILES
app/exiv2wrapper.h
app/playlistmanager.h
app/fileopeneventhandler.h
app/playlistscout.h
app/playlistscouts/qtscout.h
app/playlistscouts/explorerscout.h
)
set (PPIC_UI_FILES
app/settingsdialog.ui
app/aboutdialog.ui
app/metadatadialog.ui
app/mainwindow.ui
)
set (PPIC_QRC_FILES
@@ -119,6 +124,11 @@ else()
qt_add_translations(${EXE_NAME} ${ADD_TRANSLATIONS_ADDITIONAL_ARGS} TS_FILES ${PPIC_TS_FILES} QM_FILES_OUTPUT_VARIABLE PPIC_QM_FILES)
endif()
# YYC MARK:
# This is essential, otherwise Qt UIC generated UI header file
# can not find our header files if there are custom widgets inside UI file.
target_include_directories(${EXE_NAME} PRIVATE app/)
target_sources(${EXE_NAME} PRIVATE ${PPIC_QM_FILES})
target_link_libraries (${EXE_NAME} Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Svg Qt${QT_VERSION_MAJOR}::SvgWidgets)
+14 -8
View File
@@ -55,7 +55,7 @@ void ActionManager::setupAction(MainWindow *mainWindow)
// TODO: We may replace all Qt theme icons to our custom icon for visual consistency.
// TODO: Some icons are invalid.
CREATE_NEW_THEMEICON_ACTION(mainWindow, actionCopyPixmap, edit-copy);
CREATE_NEW_ACTION(mainWindow, actionCopyPixmap);
CREATE_NEW_ACTION(mainWindow, actionCopyFilePath);
CREATE_NEW_ICON_ACTION(mainWindow, actionPrevPicture, go-previous);
@@ -74,7 +74,7 @@ void ActionManager::setupAction(MainWindow *mainWindow)
CREATE_NEW_ICON_ACTION(mainWindow, actionRotateCounterClockwise, app-icon);
CREATE_NEW_ACTION(mainWindow, actionToggleAvoidResetTransform);
CREATE_NEW_ICON_ACTION(mainWindow, actionToggleCheckerboard, view-background-checkerboard);
CREATE_NEW_ACTION(mainWindow, actionToggleCheckerboard);
CREATE_NEW_ACTION(mainWindow, actionTogglePauseAnimation);
CREATE_NEW_ACTION(mainWindow, actionAnimationNextFrame);
@@ -179,6 +179,11 @@ void ActionManager::updateActionState(PlaylistManager* pm, GraphicsView* gv)
const auto* gs = gv->scene();
const auto* settings = Settings::instance();
// Update copy actions
bool canCopy = pm->currentIndex().has_value();
actionCopyPixmap->setEnabled(canCopy);
actionCopyFilePath->setEnabled(canCopy);
// Update Prev/Next Picture State
const int galleryFileCount = pm->totalCount();
const bool loopGallery = settings->loopGallery();
@@ -186,11 +191,9 @@ void ActionManager::updateActionState(PlaylistManager* pm, GraphicsView* gv)
actionNextPicture->setEnabled(galleryFileCount > 1 && (loopGallery || !pm->isLastIndex()));
// Update Avoid Reset Transform
actionToggleAvoidResetTransform->setCheckable(gv->avoidResetTransform());
actionToggleAvoidResetTransform->setChecked(gv->isAvoidResetTransform());
// Update Checkerboard
actionToggleCheckerboard->setChecked(gv->checkerboard());
actionToggleCheckerboard->setChecked(gv->isCheckerboard());
// Update Animation Graphics Action
bool canPauseAnimation = gs->canPauseAnimation();
actionTogglePauseAnimation->setEnabled(canPauseAnimation);
@@ -202,7 +205,10 @@ void ActionManager::updateActionState(PlaylistManager* pm, GraphicsView* gv)
bool canSkipAnimation = gs->canSkipAnimationFrame();
actionAnimationNextFrame->setEnabled(canSkipAnimation);
// TODO: Add more...
// Update trash, located in explorer and properties.
bool hasLoadedFile = pm->currentIndex().has_value();
actionTrash->setEnabled(hasLoadedFile);
actionLocateInFileManager->setEnabled(hasLoadedFile);
actionProperties->setEnabled(hasLoadedFile);
}
+7 -1
View File
@@ -8,7 +8,7 @@
#include <QVBoxLayout>
#include <QDebug>
BottomButtonGroup::BottomButtonGroup(const std::vector<QAction *> &actionList, QWidget *parent)
BottomButtonGroup::BottomButtonGroup(QWidget *parent)
: QGroupBox (parent)
{
QHBoxLayout * mainLayout = new QHBoxLayout(this);
@@ -25,7 +25,9 @@ QToolButton {
border-style: none;
}
)#");
}
void BottomButtonGroup::addActions(const std::vector<QAction *> & actionList) {
for (QAction * action : actionList) {
// Create button
QToolButton * btn = new QToolButton(this);
@@ -37,3 +39,7 @@ QToolButton {
}
updateGeometry();
}
void BottomButtonGroup::addAction(QAction* action) {
addActions({action});
}
+4 -1
View File
@@ -14,7 +14,10 @@ class BottomButtonGroup : public QGroupBox
{
Q_OBJECT
public:
explicit BottomButtonGroup(const std::vector<QAction *> & actionList, QWidget *parent = nullptr);
explicit BottomButtonGroup(QWidget *parent = nullptr);
void addActions(const std::vector<QAction *> & actionList);
void addAction(QAction* action);
};
#endif // BOTTOMBUTTONGROUP_H
-36
View File
@@ -1,36 +0,0 @@
// SPDX-FileCopyrightText: 2022 Gary Wang <wzc782970009@gmail.com>
// SPDX-FileCopyrightText: 2023 Tad Young <yyc12321@outlook.com>
//
// SPDX-License-Identifier: MIT
#include "framelesswindow.h"
#include <QMouseEvent>
#include <QHoverEvent>
#include <QApplication>
#include <QVBoxLayout>
#include <QWindow>
FramelessWindow::FramelessWindow(QWidget *parent)
: QWidget(parent)
, m_centralLayout(new QVBoxLayout(this))
{
this->setWindowFlags(Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint | Qt::WindowMinMaxButtonsHint | Qt::WindowCloseButtonHint);
// YYC MARK:
// 72 px is the height of Windows Photo Viewer's bottom line.
// TODO: This value may be declared as a constant.
m_centralLayout->setContentsMargins(QMargins(0, 0, 0, 72));
}
void FramelessWindow::setCentralWidget(QWidget *widget)
{
if (m_centralWidget) {
m_centralLayout->removeWidget(m_centralWidget);
m_centralWidget->deleteLater();
}
m_centralLayout->addWidget(widget);
m_centralWidget = widget;
}
-27
View File
@@ -1,27 +0,0 @@
// SPDX-FileCopyrightText: 2022 Gary Wang <wzc782970009@gmail.com>
//
// SPDX-License-Identifier: MIT
#ifndef FRAMELESSWINDOW_H
#define FRAMELESSWINDOW_H
#include <QWidget>
QT_BEGIN_NAMESPACE
class QVBoxLayout;
QT_END_NAMESPACE
class FramelessWindow : public QWidget
{
Q_OBJECT
public:
explicit FramelessWindow(QWidget *parent = nullptr);
void setCentralWidget(QWidget * widget);
private:
QVBoxLayout * m_centralLayout = nullptr;
QWidget * m_centralWidget = nullptr; // just a pointer, doesn't take the ownership.
};
#endif // FRAMELESSWINDOW_H
+1 -1
View File
@@ -129,7 +129,7 @@ private:
GraphicsScene::GraphicsScene(QObject *parent)
: QGraphicsScene(parent)
{
showText(tr("Drag image here"));
showText(tr("Drag image here", "The same meaning of MainWindow's hint."));
connect(qGuiApp->styleHints(), &QStyleHints::colorSchemeChanged, this, &GraphicsScene::updateStyle);
}
+1 -1
View File
@@ -38,7 +38,7 @@ public:
QPixmap renderToPixmap();
public slots:
private slots:
void updateStyle(Qt::ColorScheme colorScheme);
private:
+14 -3
View File
@@ -224,7 +224,18 @@ void GraphicsView::fitByOrientation(Qt::Orientation ori, bool scaleDownOnly)
QRectF originalScene = sceneRect();
QTransform currentTransform = transform();
// TODO: May be optimized by matrix arithmetic operation?
// TODO:
// I guess following code may be optimized by matrix arithmetic operation.
// There is an AI given solution. But I didn't use it.
// Because I want to keep the same behavior of upstream.
// // Horizontal fit: Let the position of top center of viewport, (cx, 0), be inversely mapped to scene.
// // And compensate the behavior of `centerOn` which put given point to the center of viewport.
// QTransform inv = currentTransform.inverted();
// QPointF viewCx = viewport()->rect().center().x();
// QPointF viewCy = viewport()->rect().center().y();
// // The target is viewport top center. Its y=0, so `centerOn` required scene point is (viewCx, -viewCy).
// centerOn(inv.map(QPointF(viewCx, -viewCy)));
if (ori == Qt::Horizontal) {
// For horizontal fit (fit by width), position at top (for tall images)
@@ -320,7 +331,7 @@ void GraphicsView::setEnableAutoFitToScreen(bool enable)
m_enableFitToScreen = enable;
}
bool GraphicsView::avoidResetTransform() const
bool GraphicsView::isAvoidResetTransform() const
{
return m_avoidResetTransform;
}
@@ -330,7 +341,7 @@ void GraphicsView::setAvoidResetTransform(bool avoidReset)
m_avoidResetTransform = avoidReset;
}
bool GraphicsView::checkerboard() const {
bool GraphicsView::isCheckerboard() const {
return m_checkerboardEnabled;
}
+3 -3
View File
@@ -40,10 +40,10 @@ public:
bool isSceneBiggerThanView() const;
void setEnableAutoFitToScreen(bool enable = true);
bool avoidResetTransform() const;
bool isAvoidResetTransform() const;
void setAvoidResetTransform(bool avoidReset);
bool checkerboard() const;
bool isCheckerboard() const;
void toggleCheckerboard(bool invertCheckerboardColor = false);
static QTransform resetScale(const QTransform & orig);
@@ -52,7 +52,7 @@ signals:
void navigatorViewRequired(bool required, QTransform transform);
void viewportRectChanged();
public slots:
private slots:
void updateStyle(Qt::ColorScheme colorScheme);
private:
+5 -12
View File
@@ -4,7 +4,6 @@
#include "mainwindow.h"
#include "playlistmanager.h"
#include "settings.h"
#ifdef Q_OS_MACOS
@@ -68,21 +67,19 @@ int main(int argc, char *argv[])
a.installEventFilter(fileOpenEventHandler);
a.connect(fileOpenEventHandler, &FileOpenEventHandler::fileOpen, [&w](const QUrl & url){
if (w.isHidden()) {
w.setWindowOpacity(1);
w.showNormal();
} else {
w.activateWindow();
}
w.showUrls({url});
w.showFiles({url});
w.initWindowSize();
});
// Handle dock icon clicks to show hidden window
a.connect(&a, &QApplication::applicationStateChanged, [&w](Qt::ApplicationState state) {
if (state == Qt::ApplicationActive && w.isHidden()) {
w.showUrls({});
w.galleryCurrent(true, true);
w.setWindowOpacity(1);
w.showFiles({});
w.galleryCurrent(false);
w.showNormal();
w.raise();
w.activateWindow();
@@ -90,12 +87,8 @@ int main(int argc, char *argv[])
});
#endif // Q_OS_MACOS
QStringList urlStrList = parser.positionalArguments();
QList<QUrl> && urlList = PlaylistManager::convertToUrlList(urlStrList);
if (!urlList.isEmpty()) {
w.showUrls(urlList);
}
QStringList fileList = parser.positionalArguments();
w.showFiles(fileList);
w.initWindowSize();
+160 -333
View File
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include "settings.h"
#include "bottombuttongroup.h"
@@ -46,51 +47,50 @@
using namespace Qt::Literals::StringLiterals;
MainWindow::MainWindow(QWidget *parent)
: FramelessWindow(parent)
: QWidget(parent)
, ui(new QT_PREPEND_NAMESPACE(Ui::MainWindow))
, m_am(new ActionManager)
, m_pm(new PlaylistManager(this))
, m_fileSystemWatcher(new QFileSystemWatcher(this))
{
ui->setupUi(this);
this->setWindowFlags(Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint | Qt::WindowMinMaxButtonsHint | Qt::WindowCloseButtonHint);
// YYC MARK:
// Blumis set original value is 350 x 330.
// It is too small for modern device,
// so I level it up to standard VGA resolution 640 x 480.
this->setMinimumSize(640, 480);
// so I level it up to SVGA resolution 800 x 600.
this->setMinimumSize(800, 600);
this->setWindowIcon(QIcon(u":/icons/app-icon.svg"_s));
this->setAcceptDrops(true);
m_pm->setAutoLoadFilterSuffixes(supportedImageFormats());
// Initialize playlist manager
m_pm->setAllowedSuffixes(supportedImageFormats());
// Initialize graphics scene
GraphicsScene * scene = new GraphicsScene(this);
m_gv = new GraphicsView(this);
m_gv->setScene(scene);
this->setCentralWidget(m_gv);
m_nav = new NavigatorView(this);
// YYC MARK:
// Blumia set original value is 220 x 160.
// So if we set its height to 72 for respecting the height of Windows Photo Viewer,
// the width should be set to 99 for keeping aspect ratio.
m_nav->setFixedSize(99, 72);
m_nav->setScene(scene);
m_nav->setMainView(m_gv);
m_nav->fitInView(m_nav->sceneRect(), Qt::KeepAspectRatio);
connect(m_gv, &GraphicsView::navigatorViewRequired,
// Setup graphics view (main photo view)
ui->m_gv->setScene(scene);
// Setup navigator view (right-bottom corner thumbnail view)
ui->m_nav->setScene(scene);
ui->m_nav->setMainView(ui->m_gv);
ui->m_nav->fitInView(ui->m_nav->sceneRect(), Qt::KeepAspectRatio);
// Binding graphics and navigator views signals
connect(ui->m_gv, &GraphicsView::navigatorViewRequired,
this, [this](bool required, const QTransform & tf){
m_nav->setTransform(GraphicsView::resetScale(tf));
m_nav->fitInView(m_nav->sceneRect(), Qt::KeepAspectRatio);
m_nav->setVisible(required);
m_nav->updateMainViewportRegion();
ui->m_nav->setTransform(GraphicsView::resetScale(tf));
ui->m_nav->fitInView(ui->m_nav->sceneRect(), Qt::KeepAspectRatio);
ui->m_nav->setVisible(required);
ui->m_navPH->setVisible(required);
ui->m_nav->updateMainViewportRegion();
});
connect(ui->m_gv, &GraphicsView::viewportRectChanged,
ui->m_nav, &NavigatorView::updateMainViewportRegion);
connect(m_gv, &GraphicsView::viewportRectChanged,
m_nav, &NavigatorView::updateMainViewportRegion);
// Setup action manager
m_am->setupAction(this);
m_bottomButtonGroup = new BottomButtonGroup({
// Setup bottom button group
ui->m_bottomButtonGroup->addActions({
m_am->actionActualSize,
m_am->actionFitToScreen,
m_am->actionZoomIn,
@@ -101,22 +101,70 @@ MainWindow::MainWindow(QWidget *parent)
m_am->actionRotateCounterClockwise,
m_am->actionRotateClockwise,
m_am->actionFlipHorizontal,
m_am->actionToggleCheckerboard,
}, this);
m_am->actionFlipVertical,
});
// Setup right click menus
{
ui->m_subMenuCopy->addAction(m_am->actionCopyPixmap);
ui->m_subMenuCopy->addAction(m_am->actionCopyFilePath);
}
{
ui->m_subMenuTransform->addAction(m_am->actionActualSize);
ui->m_subMenuTransform->addAction(m_am->actionFitToScreen);
ui->m_subMenuTransform->addAction(m_am->actionFitByWidth);
ui->m_subMenuTransform->addAction(m_am->actionFitByHeight);
ui->m_subMenuTransform->addAction(m_am->actionZoomIn);
ui->m_subMenuTransform->addAction(m_am->actionZoomOut);
connect(m_pm, &PlaylistManager::totalCountChanged, this, &MainWindow::updateGalleryButtonsVisibility);
ui->m_subMenuTransform->addSeparator();
connect(m_pm->model(), &PlaylistModel::modelReset, this, std::bind(&MainWindow::galleryCurrent, this, false, false));
connect(m_pm, &PlaylistManager::currentIndexChanged, this, std::bind(&MainWindow::galleryCurrent, this, true, false));
ui->m_subMenuTransform->addAction(m_am->actionFlipHorizontal);
ui->m_subMenuTransform->addAction(m_am->actionFlipVertical);
ui->m_subMenuTransform->addAction(m_am->actionRotateClockwise);
ui->m_subMenuTransform->addAction(m_am->actionRotateCounterClockwise);
}
{
ui->m_menu->addMenu(ui->m_subMenuCopy);
ui->m_menu->addSeparator();
ui->m_menu->addAction(m_am->actionPrevPicture);
ui->m_menu->addAction(m_am->actionNextPicture);
ui->m_menu->addSeparator();
ui->m_menu->addMenu(ui->m_subMenuTransform);
ui->m_menu->addSeparator();
ui->m_menu->addAction(m_am->actionToggleAvoidResetTransform);
ui->m_menu->addAction(m_am->actionToggleCheckerboard);
ui->m_menu->addAction(m_am->actionTogglePauseAnimation);
ui->m_menu->addAction(m_am->actionAnimationNextFrame);
ui->m_menu->addSeparator();
ui->m_menu->addAction(m_am->actionTrash);
ui->m_menu->addAction(m_am->actionLocateInFileManager);
ui->m_menu->addAction(m_am->actionProperties);
ui->m_menu->addSeparator();
ui->m_menu->addAction(m_am->actionSettings);
ui->m_menu->addAction(m_am->actionHelp);
}
// Connect playlist manager and file watcher signals for photo browsering.
connect(m_pm, &PlaylistManager::playlistChanged, this, std::bind(&MainWindow::updateActionState, this));
connect(m_pm, &PlaylistManager::playlistChanged, this, std::bind(&MainWindow::galleryCurrent, this, false));
connect(m_pm, &PlaylistManager::currentIndexChanged, this, std::bind(&MainWindow::galleryCurrent, this, false));
connect(m_fileSystemWatcher, &QFileSystemWatcher::fileChanged, this, [this](){
QTimer::singleShot(500, this, std::bind(&MainWindow::galleryCurrent, this, false, true));
QTimer::singleShot(500, this, std::bind(&MainWindow::galleryCurrent, this, true));
});
QShortcut * fullscreenShorucut = new QShortcut(QKeySequence(QKeySequence::FullScreen), this);
connect(fullscreenShorucut, &QShortcut::activated,
this, &MainWindow::toggleFullscreen);
// Change window location
centerWindow();
QTimer::singleShot(0, this, [this](){
@@ -127,89 +175,37 @@ MainWindow::MainWindow(QWidget *parent)
MainWindow::~MainWindow()
{
delete ui;
}
void MainWindow::showUrls(const QList<QUrl> &urls)
void MainWindow::showFiles(const QStringList &files)
{
if (!urls.isEmpty()) {
const QUrl & firstUrl = urls.first();
if (urls.count() == 1) {
const QString lowerCaseUrlPath(firstUrl.path().toLower());
if (lowerCaseUrlPath.endsWith(".m3u8") || lowerCaseUrlPath.endsWith(".m3u")) {
m_pm->loadM3U8Playlist(firstUrl);
galleryCurrent(true, true);
return;
}
}
m_gv->showFileFromPath(firstUrl.toLocalFile());
m_pm->loadPlaylist(urls);
} else {
m_gv->showText(tr("File url list is empty"));
m_pm->setPlaylist(urls);
return;
}
m_pm->loadPlaylist(files);
ui->m_nav->fitInView(ui->m_nav->sceneRect(), Qt::KeepAspectRatio);
}
m_nav->fitInView(m_nav->sceneRect(), Qt::KeepAspectRatio);
void MainWindow::showFiles(const QList<QUrl>& urls)
{
m_pm->loadPlaylist(urls);
ui->m_nav->fitInView(ui->m_nav->sceneRect(), Qt::KeepAspectRatio);
}
void MainWindow::initWindowSize()
{
switch (Settings::instance()->initWindowSizeBehavior()) {
case Settings::WindowSizeBehavior::Auto:
adjustWindowSizeBySceneRect();
break;
case Settings::WindowSizeBehavior::Maximized:
showMaximized();
break;
case Settings::WindowSizeBehavior::Windowed:
default:
showNormal();
break;
default:
adjustWindowSizeBySceneRect();
break;
}
}
void MainWindow::adjustWindowSizeBySceneRect()
{
if (m_pm->totalCount() < 1) return;
QSize sceneSize = m_gv->sceneRect().toRect().size();
QSize sceneSizeWithMargins = sceneSize + QSize(130, 125);
if (m_gv->scaleFactor() < 1 || size().expandedTo(sceneSizeWithMargins) != size()) {
// if it scaled down by the resize policy:
QSize screenSize = qApp->screenAt(QCursor::pos())->availableSize();
if (screenSize.expandedTo(sceneSize) == screenSize) {
// we can show the picture by increase the window size.
QSize finalSize = (screenSize.expandedTo(sceneSizeWithMargins) == screenSize) ?
sceneSizeWithMargins : screenSize;
// We have a very reasonable sizeHint() value ;P
this->resize(finalSize.expandedTo(this->sizeHint()));
// We're sure the window can display the whole thing with 1:1 scale.
// The old window size may cause fitInView call from resize() and the
// above resize() call won't reset the scale back to 1:1, so we
// just call resetScale() here to ensure the thing is no longer scaled.
m_gv->resetScale();
centerWindow();
} else {
// toggle maximum
showMaximized();
}
}
}
// can be empty if it is NOT from a local file.
QUrl MainWindow::currentImageFileUrl() const
{
return m_pm->urlByIndex(m_pm->curIndex());
}
void MainWindow::clearGallery()
{
m_pm->setPlaylist({});
m_pm->clearPlaylist();
}
void MainWindow::galleryPrev()
@@ -217,10 +213,9 @@ void MainWindow::galleryPrev()
const bool loopGallery = Settings::instance()->loopGallery();
if (!loopGallery && m_pm->isFirstIndex()) return;
QModelIndex index = m_pm->previousIndex();
if (index.isValid()) {
m_pm->setCurrentIndex(index);
m_gv->showFileFromPath(m_pm->localFileByIndex(index));
auto index = m_pm->previousIndex();
if (index.has_value()) {
m_pm->setCurrentIndex(*index);
}
}
@@ -229,31 +224,29 @@ void MainWindow::galleryNext()
const bool loopGallery = Settings::instance()->loopGallery();
if (!loopGallery && m_pm->isLastIndex()) return;
QModelIndex index = m_pm->nextIndex();
if (index.isValid()) {
m_pm->setCurrentIndex(index);
m_gv->showFileFromPath(m_pm->localFileByIndex(index));
auto index = m_pm->nextIndex();
if (index.has_value()) {
m_pm->setCurrentIndex(*index);
}
}
// Only use this to update minor information.
void MainWindow::galleryCurrent(bool showLoadImageHintWhenEmpty, bool reloadImage)
void MainWindow::galleryCurrent(bool fromFileWatcher)
{
QModelIndex index = m_pm->curIndex();
bool shouldResetfileWatcher = true;
if (index.isValid()) {
const QString & localFilePath(m_pm->localFileByIndex(index));
if (reloadImage) m_gv->showFileFromPath(localFilePath);
shouldResetfileWatcher = !updateFileWatcher(localFilePath);
setWindowTitle(m_pm->urlByIndex(index).fileName());
} else if (showLoadImageHintWhenEmpty && m_pm->totalCount() <= 0) {
m_gv->showText(QCoreApplication::translate("GraphicsScene", "Drag image here"));
auto index = m_pm->currentIndex();
if (index.has_value()) {
QString localFilePath = m_pm->localFileByIndex(*index);
ui->m_gv->showFileFromPath(localFilePath);
if (!fromFileWatcher) {
updateFileWatcher(localFilePath);
setWindowTitle(m_pm->urlByIndex(*index).fileName());
}
} else {
ui->m_gv->showText(tr("Drag image here"));
setWindowTitle(QString());
updateFileWatcher();
}
updateGalleryButtonsVisibility();
if (shouldResetfileWatcher) updateFileWatcher();
updateActionState();
}
QStringList MainWindow::supportedImageFormats()
@@ -269,53 +262,6 @@ QStringList MainWindow::supportedImageFormats()
return formatFilters;
}
void MainWindow::showEvent(QShowEvent *event)
{
updateWidgetsPosition();
return FramelessWindow::showEvent(event);
}
void MainWindow::enterEvent(QEnterEvent *event)
{
return FramelessWindow::enterEvent(event);
}
void MainWindow::leaveEvent(QEvent *event)
{
return FramelessWindow::leaveEvent(event);
}
void MainWindow::mouseDoubleClickEvent(QMouseEvent *event)
{
// The forward/back mouse button can also used to trigger a mouse double-click event
// Since we use that for gallery navigation so we ignore these two buttons.
if (event->buttons() & Qt::ForwardButton || event->buttons() & Qt::BackButton) {
return;
}
switch (Settings::instance()->doubleClickBehavior()) {
case Settings::DoubleClickBehavior::Close:
quitAppAction();
event->accept();
break;
case Settings::DoubleClickBehavior::Maximize:
toggleMaximize();
event->accept();
break;
case Settings::DoubleClickBehavior::FullScreen:
toggleFullscreen();
event->accept();
break;
case Settings::DoubleClickBehavior::Ignore:
break;
}
// blumia: don't call parent constructor here, seems it will cause mouse move
// event get called even if we set event->accept();
// return QMainWindow::mouseDoubleClickEvent(event);
}
void MainWindow::wheelEvent(QWheelEvent *event)
{
QPoint numDegrees = event->angleDelta() / 8;
@@ -348,102 +294,26 @@ void MainWindow::wheelEvent(QWheelEvent *event)
}
event->accept();
} else {
FramelessWindow::wheelEvent(event);
QWidget::wheelEvent(event);
}
}
void MainWindow::resizeEvent(QResizeEvent *event)
{
updateWidgetsPosition();
return FramelessWindow::resizeEvent(event);
}
void MainWindow::contextMenuEvent(QContextMenuEvent *event)
{
// TODO: Update this function.
QMenu * menu = new QMenu;
ui->m_menu->exec(mapToGlobal(event->pos()));
// TODO: currentImageFileUrl() may be redundant.
QMenu * copyMenu = new QMenu(tr("&Copy"));
QUrl currentFileUrl = currentImageFileUrl();
copyMenu->setIcon(QIcon::fromTheme(u"edit-copy"_s));
copyMenu->addAction(m_am->actionCopyPixmap);
if (currentFileUrl.isValid()) {
copyMenu->addAction(m_am->actionCopyFilePath);
}
if (copyMenu->actions().count() == 1) {
menu->addActions(copyMenu->actions());
} else {
menu->addMenu(copyMenu);
}
menu->addSeparator();
menu->addAction(m_am->actionPrevPicture);
menu->addAction(m_am->actionNextPicture);
menu->addSeparator();
menu->addAction(m_am->actionActualSize);
menu->addAction(m_am->actionFitToScreen);
menu->addAction(m_am->actionFitByWidth);
menu->addAction(m_am->actionFitByHeight);
menu->addAction(m_am->actionZoomIn);
menu->addAction(m_am->actionZoomOut);
menu->addSeparator();
menu->addAction(m_am->actionFlipHorizontal);
menu->addAction(m_am->actionFlipVertical);
menu->addAction(m_am->actionRotateClockwise);
menu->addAction(m_am->actionRotateCounterClockwise);
menu->addSeparator();
// TODO: Move checked update to action manager.
QAction * avoidResetTransform = m_am->actionToggleAvoidResetTransform;
avoidResetTransform->setCheckable(true);
avoidResetTransform->setChecked(m_gv->avoidResetTransform());
menu->addAction(avoidResetTransform);
menu->addAction(m_am->actionToggleCheckerboard);
QAction * pauseAnimation = m_am->actionTogglePauseAnimation;
pauseAnimation->setCheckable(true);
pauseAnimation->setChecked(m_gv->scene()->pauseAnimation());
menu->addAction(pauseAnimation);
menu->addAction(m_am->actionAnimationNextFrame);
if (currentFileUrl.isValid()) {
menu->addSeparator();
if (currentFileUrl.isLocalFile()) {
menu->addAction(m_am->actionTrash);
menu->addAction(m_am->actionLocateInFileManager);
}
menu->addAction(m_am->actionProperties);
}
menu->addSeparator();
menu->addAction(m_am->actionSettings);
menu->addAction(m_am->actionHelp);
menu->exec(mapToGlobal(event->pos()));
menu->deleteLater();
copyMenu->deleteLater();
return FramelessWindow::contextMenuEvent(event);
return QWidget::contextMenuEvent(event);
}
void MainWindow::dragEnterEvent(QDragEnterEvent *event)
{
if (event->mimeData()->hasUrls() || event->mimeData()->hasImage() || event->mimeData()->hasText()) {
if (event->mimeData()->hasUrls()) {
event->acceptProposedAction();
} else {
event->ignore();
}
return FramelessWindow::dragEnterEvent(event);
return QWidget::dragEnterEvent(event);
}
void MainWindow::dragMoveEvent(QDragMoveEvent *event)
@@ -456,26 +326,10 @@ void MainWindow::dropEvent(QDropEvent *event)
event->acceptProposedAction();
const QMimeData * mimeData = event->mimeData();
if (mimeData->hasUrls()) {
const QList<QUrl> &urls = mimeData->urls();
if (urls.isEmpty()) {
m_gv->showText(tr("File url list is empty"));
} else {
showUrls(urls);
}
} else if (mimeData->hasImage()) {
QImage img = qvariant_cast<QImage>(mimeData->imageData());
QPixmap pixmap = QPixmap::fromImage(img);
if (pixmap.isNull()) {
m_gv->showText(tr("Image data is invalid"));
} else {
m_gv->showImage(pixmap);
}
} else if (mimeData->hasText()) {
m_gv->showText(mimeData->text());
showFiles(mimeData->urls());
} else {
m_gv->showText(tr("Not supported mimedata: %1").arg(mimeData->formats().first()));
ui->m_gv->showText(tr("Not supported mimedata: %1").arg(mimeData->formats().first()));
}
}
@@ -491,43 +345,9 @@ void MainWindow::centerWindow()
);
}
void MainWindow::closeWindow()
{
}
void MainWindow::updateWidgetsPosition()
{
m_bottomButtonGroup->move((width() - m_bottomButtonGroup->width()) / 2,
(72 - m_bottomButtonGroup->height()) / 2 + (height() - 72));
m_nav->move(width() - m_nav->width(), height() - m_nav->height());
}
void MainWindow::toggleAvoidResetTransform()
{
m_gv->setAvoidResetTransform(!m_gv->avoidResetTransform());
}
bool MainWindow::canPaste() const
{
const QMimeData * clipboardData = QApplication::clipboard()->mimeData();
if (clipboardData->hasImage()) {
return true;
} else if (clipboardData->hasText()) {
QString clipboardText(clipboardData->text());
if (clipboardText.startsWith("PICTURE:")) {
QString maybeFilename(clipboardText.mid(8));
if (QFile::exists(maybeFilename)) {
return true;
}
}
}
return false;
}
void MainWindow::quitAppAction(bool force)
{
closeWindow();
ui->m_gv->setAvoidResetTransform(!ui->m_gv->isAvoidResetTransform());
}
void MainWindow::toggleFullscreen()
@@ -558,16 +378,16 @@ QSize MainWindow::sizeHint() const
void MainWindow::on_actionCopyPixmap_triggered()
{
QClipboard *cb = QApplication::clipboard();
cb->setPixmap(m_gv->scene()->renderToPixmap());
cb->setPixmap(ui->m_gv->scene()->renderToPixmap());
}
void MainWindow::on_actionCopyFilePath_triggered()
{
QUrl currentFileUrl(currentImageFileUrl());
if (currentFileUrl.isValid()) {
QClipboard *cb = QApplication::clipboard();
cb->setText(currentFileUrl.toLocalFile());
}
auto index = m_pm->currentIndex();
if (!index.has_value()) return;
QClipboard *cb = QApplication::clipboard();
cb->setText(m_pm->localFileByIndex(*index));
}
void MainWindow::on_actionPrevPicture_triggered()
@@ -582,58 +402,58 @@ void MainWindow::on_actionNextPicture_triggered()
void MainWindow::on_actionActualSize_triggered()
{
m_gv->resetScale();
m_gv->setEnableAutoFitToScreen(false);
ui->m_gv->resetScale();
ui->m_gv->setEnableAutoFitToScreen(false);
}
void MainWindow::on_actionFitToScreen_triggered()
{
m_gv->fitInView(m_nav->sceneRect(), Qt::KeepAspectRatio);
m_gv->setEnableAutoFitToScreen(m_gv->scaleFactor() <= 1);
ui->m_gv->fitInView(ui->m_nav->sceneRect(), Qt::KeepAspectRatio);
ui->m_gv->setEnableAutoFitToScreen(ui->m_gv->scaleFactor() <= 1);
}
void MainWindow::on_actionFitByWidth_triggered()
{
m_gv->fitByOrientation(Qt::Orientation::Horizontal);
ui->m_gv->fitByOrientation(Qt::Orientation::Horizontal);
}
void MainWindow::on_actionFitByHeight_triggered()
{
m_gv->fitByOrientation(Qt::Orientation::Vertical);
ui->m_gv->fitByOrientation(Qt::Orientation::Vertical);
}
void MainWindow::on_actionZoomIn_triggered()
{
if (m_gv->scaleFactor() < 1000) {
m_gv->zoomView(1.25);
if (ui->m_gv->scaleFactor() < 1000) {
ui->m_gv->zoomView(1.25);
}
}
void MainWindow::on_actionZoomOut_triggered()
{
m_gv->zoomView(0.8);
ui->m_gv->zoomView(0.8);
}
void MainWindow::on_actionFlipHorizontal_triggered()
{
m_gv->flipView(true);
ui->m_gv->flipView(true);
}
void MainWindow::on_actionFlipVertical_triggered()
{
m_gv->flipView(false);
ui->m_gv->flipView(false);
}
void MainWindow::on_actionRotateClockwise_triggered()
{
m_gv->rotateView(true);
m_gv->displayScene();
ui->m_gv->rotateView(true);
ui->m_gv->displayScene();
}
void MainWindow::on_actionRotateCounterClockwise_triggered()
{
m_gv->rotateView(false);
m_gv->displayScene();
ui->m_gv->rotateView(false);
ui->m_gv->displayScene();
}
void MainWindow::on_actionToggleAvoidResetTransform_triggered()
@@ -643,17 +463,17 @@ void MainWindow::on_actionToggleAvoidResetTransform_triggered()
void MainWindow::on_actionToggleCheckerboard_triggered()
{
m_gv->toggleCheckerboard(QGuiApplication::queryKeyboardModifiers().testFlag(Qt::ShiftModifier));
ui->m_gv->toggleCheckerboard(QGuiApplication::queryKeyboardModifiers().testFlag(Qt::ShiftModifier));
}
void MainWindow::on_actionTogglePauseAnimation_triggered()
{
m_gv->scene()->togglePauseAnimation();
ui->m_gv->scene()->togglePauseAnimation();
}
void MainWindow::on_actionAnimationNextFrame_triggered()
{
m_gv->scene()->skipAnimationFrame(1);
ui->m_gv->scene()->skipAnimationFrame(1);
}
void MainWindow::on_actionTrash_triggered()
@@ -673,15 +493,16 @@ void MainWindow::on_actionTrash_triggered()
QMessageBox::warning(this, tr("Failed to move file to trash"),
tr("Move to trash failed, it might caused by file permission issue, file system limitation, or platform limitation."));
} else {
m_pm->removeAt(index);
galleryCurrent(true, true);
m_pm->removeFromPlaylist(*index);
}
}
}
void MainWindow::on_actionLocateInFileManager_triggered()
{
QUrl currentFileUrl = currentImageFileUrl();
auto index = m_pm->currentIndex();
if (!index.has_value()) return;
QUrl currentFileUrl = m_pm->urlByIndex(*index);
if (!currentFileUrl.isValid()) return;
QFileInfo fileInfo(currentFileUrl.toLocalFile());
@@ -716,7 +537,9 @@ void MainWindow::on_actionLocateInFileManager_triggered()
void MainWindow::on_actionProperties_triggered()
{
QUrl currentFileUrl = currentImageFileUrl();
auto index = m_pm->currentIndex();
if (!index.has_value()) return;
QUrl currentFileUrl = m_pm->urlByIndex(*index);
if (!currentFileUrl.isValid()) return;
MetadataModel * md = new MetadataModel();
@@ -750,3 +573,7 @@ bool MainWindow::updateFileWatcher(const QString &basePath)
if (!basePath.isEmpty()) return m_fileSystemWatcher->addPath(basePath);
return false;
}
void MainWindow::updateActionState() {
m_am->updateActionState(m_pm, ui->m_gv);
}
+12 -27
View File
@@ -5,23 +5,19 @@
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include "framelesswindow.h"
#include <QParallelAnimationGroup>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QWidget>
QT_BEGIN_NAMESPACE
class QGraphicsView;
class QFileSystemWatcher;
namespace Ui {
class MainWindow;
}
QT_END_NAMESPACE
class ActionManager;
class PlaylistManager;
class GraphicsView;
class NavigatorView;
class BottomButtonGroup;
class MainWindow : public FramelessWindow
class MainWindow : public QWidget
{
Q_OBJECT
@@ -29,36 +25,26 @@ public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow() override;
void showUrls(const QList<QUrl> &urls);
void showFiles(const QStringList& files);
void showFiles(const QList<QUrl>& urls);
void initWindowSize();
void adjustWindowSizeBySceneRect();
QUrl currentImageFileUrl() const;
void clearGallery();
void galleryPrev();
void galleryNext();
void galleryCurrent(bool showLoadImageHintWhenEmpty, bool reloadImage);
void galleryCurrent(bool fromFileWatcher);
static QStringList supportedImageFormats();
protected slots:
void showEvent(QShowEvent *event) override;
void enterEvent(QEnterEvent *event) override;
void leaveEvent(QEvent *event) override;
void mouseDoubleClickEvent(QMouseEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
void contextMenuEvent(QContextMenuEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dropEvent(QDropEvent *event) override;
void centerWindow();
void closeWindow();
void updateWidgetsPosition();
void toggleAvoidResetTransform();
bool canPaste() const;
void quitAppAction(bool force = false);
void toggleFullscreen();
void toggleMaximize();
@@ -98,15 +84,14 @@ private slots:
private:
bool updateFileWatcher(const QString & basePath = QString());
void updateActionState();
private:
QT_PREPEND_NAMESPACE(Ui::MainWindow) *ui;
ActionManager *m_am;
PlaylistManager *m_pm;
GraphicsView *m_gv;
NavigatorView *m_nav;
QFileSystemWatcher *m_fileSystemWatcher;
BottomButtonGroup *m_bottomButtonGroup;
};
#endif // MAINWINDOW_H
+172
View File
@@ -0,0 +1,172 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QWidget" name="MainWindow">
<property name="windowModality">
<enum>Qt::NonModal</enum>
</property>
<property name="minimumSize">
<size>
<width>640</width>
<height>480</height>
</size>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="m_centralLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="GraphicsView" name="m_gv">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="verticalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
</widget>
</item>
<item>
<widget class="QWidget" name="bottomBar" native="true">
<property name="minimumSize">
<size>
<width>0</width>
<height>72</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>72</height>
</size>
</property>
<layout class="QHBoxLayout" name="bottomBarLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QWidget" name="m_navPH" native="true">
<property name="minimumSize">
<size>
<width>99</width>
<height>72</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>99</width>
<height>72</height>
</size>
</property>
</widget>
</item>
<item>
<spacer name="leftSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</spacer>
</item>
<item>
<widget class="BottomButtonGroup" name="m_bottomButtonGroup"/>
</item>
<item>
<spacer name="rightSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</spacer>
</item>
<item>
<widget class="NavigatorView" name="m_nav">
<property name="minimumSize">
<size>
<width>99</width>
<height>72</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>99</width>
<height>72</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="verticalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
<widget class="QMenu" name="m_subMenuCopy">
<property name="title">
<string>&amp;Copy</string>
</property>
<property name="icon">
<iconset theme="edit-copy"/>
</property>
</widget>
<widget class="QMenu" name="m_subMenuTransform">
<property name="title">
<string>&amp;Transform</string>
</property>
</widget>
<widget class="QMenu" name="m_menu"/>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsView</class>
<extends>QGraphicsView</extends>
<header>graphicsview.h</header>
</customwidget>
<customwidget>
<class>NavigatorView</class>
<extends>QGraphicsView</extends>
<header>navigatorview.h</header>
</customwidget>
<customwidget>
<class>BottomButtonGroup</class>
<extends>QGroupBox</extends>
<header>bottombuttongroup.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>
+2
View File
@@ -18,6 +18,8 @@ public:
public slots:
void updateMainViewportRegion();
private slots:
void updateStyle(Qt::ColorScheme colorScheme);
private:
+213 -201
View File
@@ -3,158 +3,20 @@
// SPDX-License-Identifier: MIT
#include "playlistmanager.h"
#include "playlistscout.h"
#include <QCollator>
#include <QDir>
#include <QFileInfo>
#include <QUrl>
PlaylistModel::PlaylistModel(QObject *parent)
: QAbstractListModel(parent)
{
}
PlaylistModel::~PlaylistModel()
= default;
void PlaylistModel::setPlaylist(const QList<QUrl> &urls)
{
beginResetModel();
m_playlist = urls;
endResetModel();
}
QModelIndex PlaylistModel::loadPlaylist(const QList<QUrl> & urls)
{
if (urls.isEmpty()) return {};
if (urls.count() == 1) {
return loadPlaylist(urls.constFirst());
} else {
setPlaylist(urls);
return index(0);
}
}
QModelIndex PlaylistModel::loadPlaylist(const QUrl &url)
{
QFileInfo info(url.toLocalFile());
QDir dir(info.path());
QString && currentFileName = info.fileName();
if (dir.path() == m_currentDir) {
int idx = indexOf(url);
return idx == -1 ? appendToPlaylist(url) : index(idx);
}
QStringList entryList = dir.entryList(
m_autoLoadSuffixes,
QDir::Files | QDir::NoSymLinks, QDir::NoSort);
QCollator collator;
collator.setNumericMode(true);
std::sort(entryList.begin(), entryList.end(), collator);
QList<QUrl> playlist;
int idx = -1;
for (int i = 0; i < entryList.count(); i++) {
const QString & fileName = entryList.at(i);
const QString & oneEntry = dir.absoluteFilePath(fileName);
const QUrl & url = QUrl::fromLocalFile(oneEntry);
playlist.append(url);
if (fileName == currentFileName) {
idx = i;
}
}
if (idx == -1) {
idx = playlist.count();
playlist.append(url);
}
m_currentDir = dir.path();
setPlaylist(playlist);
return index(idx);
}
QModelIndex PlaylistModel::appendToPlaylist(const QUrl &url)
{
const int lastIndex = rowCount();
beginInsertRows(QModelIndex(), lastIndex, lastIndex);
m_playlist.append(url);
endInsertRows();
return index(lastIndex);
}
bool PlaylistModel::removeAt(int index)
{
if (index < 0 || index >= rowCount()) return false;
beginRemoveRows(QModelIndex(), index, index);
m_playlist.removeAt(index);
endRemoveRows();
return true;
}
int PlaylistModel::indexOf(const QUrl &url) const
{
return m_playlist.indexOf(url);
}
QUrl PlaylistModel::urlByIndex(int index) const
{
return m_playlist.value(index);
}
QStringList PlaylistModel::autoLoadFilterSuffixes() const
{
return m_autoLoadSuffixes;
}
QHash<int, QByteArray> PlaylistModel::roleNames() const
{
QHash<int, QByteArray> result = QAbstractListModel::roleNames();
result.insert(UrlRole, "url");
return result;
}
int PlaylistModel::rowCount(const QModelIndex &parent) const
{
return m_playlist.count();
}
QVariant PlaylistModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid()) return {};
switch (role) {
case Qt::DisplayRole:
return m_playlist.at(index.row()).fileName();
case UrlRole:
return m_playlist.at(index.row());
}
return {};
}
PlaylistManager::PlaylistManager(QObject *parent)
: QObject(parent)
, m_currentIndex(std::nullopt)
, m_playlist()
, m_allowedSuffixes()
{
connect(&m_model, &PlaylistModel::rowsRemoved, this,
[this](const QModelIndex &, int, int) {
if (m_model.rowCount() <= m_currentIndex) {
setProperty("currentIndex", m_currentIndex - 1);
}
});
auto onRowCountChanged = [this](){
emit totalCountChanged(m_model.rowCount());
};
connect(&m_model, &PlaylistModel::rowsInserted, this, onRowCountChanged);
connect(&m_model, &PlaylistModel::rowsRemoved, this, onRowCountChanged);
connect(&m_model, &PlaylistModel::modelReset, this, onRowCountChanged);
}
PlaylistManager::~PlaylistManager()
@@ -162,35 +24,86 @@ PlaylistManager::~PlaylistManager()
}
PlaylistModel *PlaylistManager::model()
{
return &m_model;
// region: Allowed Suffixes Operations
const QStringList& PlaylistManager::allowedSuffixes() const {
return m_allowedSuffixes;
}
void PlaylistManager::setPlaylist(const QList<QUrl> &urls)
{
m_model.setPlaylist(urls);
void PlaylistManager::setAllowedSuffixes(const QStringList& nameFilters) {
m_allowedSuffixes = nameFilters;
emit allowedSuffixesChanged(m_allowedSuffixes);
}
QModelIndex PlaylistManager::loadPlaylist(const QList<QUrl> &urls)
{
QModelIndex idx = m_model.loadPlaylist(urls);
setProperty("currentIndex", idx.row());
return idx;
// endregion
// region: Playlist Loader and Reset
bool PlaylistManager::loadPlaylist(const QStringList& files) {
// Convert string list to url list first
QList<QUrl> urls;
for (const QString & file : std::as_const(files)) {
urls.append(urlFromString(file));
}
return loadPlaylist(urls);
}
QModelIndex PlaylistManager::loadPlaylist(const QUrl &url)
{
QModelIndex idx = m_model.loadPlaylist(url);
setProperty("currentIndex", idx.row());
return idx;
bool PlaylistManager::loadPlaylist(const QList<QUrl>& urls) {
// Check its count, and check M3U8 playlist for single file case.
if (urls.isEmpty()) {
clearPlaylist();
return true;
} else if (urls.count() == 1) {
const QUrl& firstUrl = urls.first();
const QString lowerCaseUrlPath(firstUrl.path().toLower());
if (lowerCaseUrlPath.endsWith(".m3u8") || lowerCaseUrlPath.endsWith(".m3u")) {
return loadM3U8Playlist(firstUrl);
} else {
return loadSingleFilePlaylist(firstUrl);
}
} else {
return loadMultipleFilesPlaylist(std::move(urls));
}
}
QModelIndex PlaylistManager::loadM3U8Playlist(const QUrl &url)
{
bool PlaylistManager::loadSingleFilePlaylist(const QUrl& url) {
// Check URL at first
if (!checkUrl(url))
return false;
// Scout playlist by given file
auto scoutResult = PlaylistScout::scout(url, m_allowedSuffixes);
if (!scoutResult.has_value()) {
return false;
}
// Setup playlist with index
setPlaylist(std::move(scoutResult->playlist), scoutResult->indexHint);
return true;
}
bool PlaylistManager::loadMultipleFilesPlaylist(const QList<QUrl>&& urls) {
// Check all of them are valid
for (const auto& url : std::as_const(urls)) {
if (!checkUrl(url)) {
return false;
}
}
// Set playlist and return
setPlaylist(std::move(urls));
return true;
}
bool PlaylistManager::loadM3U8Playlist(const QUrl& url) {
// Check URL at first
if (!checkUrl(url))
return false;
QList<QUrl> urls;
QFile file(url.toLocalFile());
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
QList<QUrl> urls;
while (!file.atEnd()) {
QString line = file.readLine();
if (line.startsWith('#')) {
@@ -198,86 +111,185 @@ QModelIndex PlaylistManager::loadM3U8Playlist(const QUrl &url)
}
QFileInfo fileInfo(file);
QUrl item = QUrl::fromUserInput(line, fileInfo.absolutePath());
if (!checkUrl(item)) {
return false;
}
urls.append(item);
}
return loadPlaylist(urls);
} else {
return {};
return false;
}
setPlaylist(std::move(urls));
return true;
}
void PlaylistManager::setPlaylist(const QList<QUrl>&& urls, std::optional<qsizetype> indexHint) {
// Setup playlist
m_playlist = std::move(urls);
// Set index with hint
if (indexHint.has_value()) {
m_currentIndex = *indexHint;
}
sanitizeIndex();
// Raise signal
emit playlistChanged(totalCount());
}
void PlaylistManager::clearPlaylist() {
m_playlist.clear();
sanitizeIndex();
emit playlistChanged(this->totalCount());
}
// endregion
// region: Playlist Operations
void PlaylistManager::appendToPlaylist(const QString &file)
{
return appendToPlaylist(urlFromString(file));
}
void PlaylistManager::appendToPlaylist(const QUrl &url)
{
m_playlist.append(url);
sanitizeIndex();
emit playlistChanged(totalCount());
}
bool PlaylistManager::insertIntoPlaylist(qsizetype index, const QString &file)
{
return insertIntoPlaylist(index, urlFromString(file));
}
bool PlaylistManager::insertIntoPlaylist(qsizetype index, const QUrl &url)
{
if (index < 0 || index > totalCount()) {
return false;
} else {
m_playlist.insert(index, url);
sanitizeIndex();
emit playlistChanged(totalCount());
return true;
}
}
int PlaylistManager::totalCount() const
bool PlaylistManager::removeFromPlaylist(qsizetype index)
{
return m_model.rowCount();
if (index < 0 || index >= totalCount()) {
return false;
} else {
m_playlist.removeAt(index);
sanitizeIndex();
emit playlistChanged(totalCount());
return true;
}
}
QModelIndex PlaylistManager::previousIndex() const
{
int count = totalCount();
if (count == 0) return {};
// endregion
return m_model.index(isFirstIndex() ? count - 1 : m_currentIndex - 1);
// region: Index Operations
qsizetype PlaylistManager::totalCount() const
{
return m_playlist.count();
}
QModelIndex PlaylistManager::nextIndex() const
std::optional<qsizetype> PlaylistManager::previousIndex() const
{
int count = totalCount();
if (count == 0) return {};
return m_model.index(isLastIndex() ? 0 : m_currentIndex + 1);
if (!m_currentIndex.has_value() || totalCount() <= 0) return std::nullopt;
return (isFirstIndex() ? totalCount() - 1 : *m_currentIndex - 1);
}
QModelIndex PlaylistManager::curIndex() const
std::optional<qsizetype> PlaylistManager::nextIndex() const
{
return m_model.index(m_currentIndex);
if (!m_currentIndex.has_value() || totalCount() <= 0) return std::nullopt;
return (isLastIndex() ? 0 : *m_currentIndex + 1);
}
bool PlaylistManager::isFirstIndex() const
{
return m_currentIndex == 0;
return m_currentIndex.has_value() && *m_currentIndex == 0;
}
bool PlaylistManager::isLastIndex() const
{
return m_currentIndex + 1 == totalCount();
return m_currentIndex.has_value() && *m_currentIndex + 1 == totalCount();
}
void PlaylistManager::setCurrentIndex(const QModelIndex &index)
std::optional<qsizetype> PlaylistManager::currentIndex() const
{
if (index.isValid() && index.row() >= 0 && index.row() < totalCount()) {
setProperty("currentIndex", index.row());
return m_currentIndex;
}
bool PlaylistManager::setCurrentIndex(qsizetype index)
{
if (!m_currentIndex.has_value()) {
return false;
}
if (index < 0 || index >= totalCount()) {
return false;
}
if (*m_currentIndex == index) {
return true;
}
m_currentIndex = index;
emit currentIndexChanged(index);
return true;
}
QUrl PlaylistManager::urlByIndex(const QModelIndex &index)
QUrl PlaylistManager::urlByIndex(qsizetype index) const
{
return m_model.urlByIndex(index.row());
return m_playlist.value(index);
}
QString PlaylistManager::localFileByIndex(const QModelIndex &index)
QString PlaylistManager::localFileByIndex(qsizetype index) const
{
return urlByIndex(index).toLocalFile();
}
bool PlaylistManager::removeAt(const QModelIndex &index)
{
return m_model.removeAt(index.row());
}
// endregion
void PlaylistManager::setAutoLoadFilterSuffixes(const QStringList &nameFilters)
{
m_model.setProperty("autoLoadFilterSuffixes", nameFilters);
}
// region: Misc
QList<QUrl> PlaylistManager::convertToUrlList(const QStringList &files)
void PlaylistManager::sanitizeIndex()
{
QList<QUrl> urlList;
for (const QString & str : std::as_const(files)) {
QUrl url = QUrl::fromLocalFile(str);
if (url.isValid()) {
urlList.append(url);
auto cnt = totalCount();
if (cnt == 0) {
// If playlist is empty, current index should be invalid.
m_currentIndex = std::nullopt;
} else {
if (m_currentIndex.has_value()) {
// Clamp current index in playlist range.
// If it is already in range, not change was done.
auto currentIndex = *m_currentIndex;
if (currentIndex < 0) {
m_currentIndex = 0;
} else if (currentIndex >= cnt) {
m_currentIndex = cnt - 1;
} else {
; // No change.
}
} else {
// If there is no set value for current index,
// set it at the beginning of playlist.
m_currentIndex = 0;
}
}
return urlList;
}
bool PlaylistManager::checkUrl(const QUrl& url)
{
return url.isValid() && url.isLocalFile();
}
QUrl PlaylistManager::urlFromString(const QString& file)
{
return QUrl::fromLocalFile(file);
}
// endregion
+107 -65
View File
@@ -5,84 +5,126 @@
#pragma once
#include <QUrl>
#include <QAbstractListModel>
class PlaylistModel : public QAbstractListModel
{
Q_OBJECT
public:
enum PlaylistRole {
UrlRole = Qt::UserRole
};
Q_ENUM(PlaylistRole)
Q_PROPERTY(QStringList autoLoadFilterSuffixes MEMBER m_autoLoadSuffixes NOTIFY autoLoadFilterSuffixesChanged)
explicit PlaylistModel(QObject *parent = nullptr);
~PlaylistModel() override;
void setPlaylist(const QList<QUrl> & urls);
QModelIndex loadPlaylist(const QList<QUrl> & urls);
QModelIndex loadPlaylist(const QUrl & url);
QModelIndex appendToPlaylist(const QUrl & url);
bool removeAt(int index);
int indexOf(const QUrl & url) const;
QUrl urlByIndex(int index) const;
QStringList autoLoadFilterSuffixes() const;
QHash<int, QByteArray> roleNames() const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
signals:
void autoLoadFilterSuffixesChanged(QStringList suffixes);
private:
// model data
QList<QUrl> m_playlist;
// properties
QStringList m_autoLoadSuffixes = {};
// internal
QString m_currentDir;
};
#include <QObject>
#include <optional>
/// The manager of playlist.
class PlaylistManager : public QObject
{
Q_OBJECT
public:
Q_PROPERTY(int currentIndex MEMBER m_currentIndex NOTIFY currentIndexChanged)
Q_PROPERTY(QStringList autoLoadFilterSuffixes WRITE setAutoLoadFilterSuffixes)
Q_PROPERTY(PlaylistModel * model READ model CONSTANT)
explicit PlaylistManager(QObject *parent = nullptr);
/// Constructs a PlaylistManager with an optional parent QObject.
explicit PlaylistManager(QObject* parent = nullptr);
/// Destructor.
~PlaylistManager();
PlaylistModel * model();
/// Get the allowed file suffixes of this playlist.
/// @return The current list of allowed suffixes.
const QStringList& allowedSuffixes() const;
/// Set the allowed file suffixes of this playlist.
/// @remark This only works for "load the whole directory by one file" case.
/// @param[in] nameFilters The new list of allowed suffixes.
void setAllowedSuffixes(const QStringList& nameFilters);
void setPlaylist(const QList<QUrl> & url);
Q_INVOKABLE QModelIndex loadPlaylist(const QList<QUrl> & urls);
Q_INVOKABLE QModelIndex loadPlaylist(const QUrl & url);
Q_INVOKABLE QModelIndex loadM3U8Playlist(const QUrl & url);
/// Load user specified files as playlist.
/// @remark The wrapper for another overload.
/// @return True for successful load, otherwise false.
bool loadPlaylist(const QStringList& files);
/// Load user specified URLs as playlist.
/// @remark This function automatically dispatch these files into correct sub-load functions.
/// @return True for successful load, otherwise false.
bool loadPlaylist(const QList<QUrl>& urls);
/// Clear the entire playlist and reset current index.
void clearPlaylist();
int totalCount() const;
QModelIndex previousIndex() const;
QModelIndex nextIndex() const;
QModelIndex curIndex() const;
/// Append a local file path to the playlist.
void appendToPlaylist(const QString& file);
/// Append a URL to the playlist.
void appendToPlaylist(const QUrl& url);
/// Insert a local file path at the given index.
/// @return True if inserted successfully, false if index is out of range.
bool insertIntoPlaylist(qsizetype index, const QString& file);
/// Insert a URL at the given index.
/// @return True if inserted successfully, false if index is out of range.
bool insertIntoPlaylist(qsizetype index, const QUrl& url);
/// Remove the entry at the given index.
/// @return True if removed successfully, false if index is out of range.
bool removeFromPlaylist(qsizetype index);
/// Returns the total number of items in the playlist.
/// @return Total count of entries in the playlist.
qsizetype totalCount() const;
/// Try fetching previous index.
/// @details This previous index is gallery-loop-aware.
/// @return std::nullopt if playlist is empty, otherwise the previous index considering gallery loop.
std::optional<qsizetype> previousIndex() const;
/// Try fetching next index.
/// @details This next index is gallery-loop-aware.
/// @return std::nullopt if playlist is empty, otherwise the next index considering gallery loop.
std::optional<qsizetype> nextIndex() const;
/// Check whether current index is the first index.
/// @return False if current index is not the first index or playlist is empty, otherwise true.
bool isFirstIndex() const;
/// Check whether current index is the last index.
/// @return False if current index is not the last index or playlist is empty, otherwise true.
bool isLastIndex() const;
void setCurrentIndex(const QModelIndex & index);
QUrl urlByIndex(const QModelIndex & index);
QString localFileByIndex(const QModelIndex & index);
bool removeAt(const QModelIndex & index);
void setAutoLoadFilterSuffixes(const QStringList &nameFilters);
static QList<QUrl> convertToUrlList(const QStringList & files);
/// Returns the current index.
/// @return std::nullopt if the playlist is empty, otherwise the current index.
std::optional<qsizetype> currentIndex() const;
/// Set the current index.
/// @remark Index only can be set when playlist is not empty.
/// @return True if successful set, including "not changed" set, otherwise false.
/// This return value only indicates whether we can set index and given index is valid for setting.
/// If "no changed" set occurs, this function returns true, but doesn't raise current index changed signal.
bool setCurrentIndex(qsizetype index);
/// Returns the URL at the given index.
/// @return The URL if index is valid.
/// This class guarantee that this QUrl is pointing to a local file.
QUrl urlByIndex(qsizetype index) const;
/// Returns the local file path at the given index.
/// @return The local file path if index is valid.
/// This class guarantee that local file path always is valid.
QString localFileByIndex(qsizetype index) const;
signals:
void currentIndexChanged(int index);
void totalCountChanged(int count);
void allowedSuffixesChanged(const QStringList& suffixes);
/// Raised when index was changed bu setter.
/// The argument holds the new current index.
/// Please note that this signal will NOT be raised by the whole playlist change caused index change.
void currentIndexChanged(qsizetype index);
/// The whole playlist was changed, involving loading, editing and deleting some or full entries.
/// The argument holds the total count of new play list.
void playlistChanged(qsizetype count);
private:
int m_currentIndex = -1;
PlaylistModel m_model;
/// The fundamental playlist setter.
/// @details All playlist load functions will call this function to set playlist at last.
/// @param[in] urls ALl urls in this list should be checked by checkUrl() function.
/// @param[in] indexHint Try to set current index to this index if possible.
void setPlaylist(const QList<QUrl>&& urls, std::optional<qsizetype> indexHint = std::nullopt);
/// Load playlist from a single file.
/// @details This function scans the same directory for siblings matching allowed suffixes, sorted naturally.
/// @return True if the playlist was loaded successfully, false otherwise.
bool loadSingleFilePlaylist(const QUrl& url);
/// Load playlist from multiple URLs directly.
/// @return True if all URLs are valid, false otherwise.
bool loadMultipleFilesPlaylist(const QList<QUrl>&& urls);
/// Load playlist from an M3U/M3U8 playlist file.
/// @return True if loaded successfully, false otherwise.
bool loadM3U8Playlist(const QUrl& url);
/// Make sure that current index is valid.
/// @details This function usually is used by those functions manipulating playlist.
void sanitizeIndex();
/// Check whether given QUrl is proper to be put into playlist.
/// @details Only the url pointing to local file can be put into playlist.
static bool checkUrl(const QUrl& url);
/// Convert a string to local file into a QUrl.
/// @param[in] file The string pointing to local file.
static QUrl urlFromString(const QString& file);
std::optional<qsizetype> m_currentIndex;
QList<QUrl> m_playlist;
QStringList m_allowedSuffixes;
};
+25
View File
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2026 yyc12345 <yyc12321@outlook.com>
//
// SPDX-License-Identifier: MIT
#include "playlistscout.h"
#include "playlistscouts/qtscout.h"
#include "playlistscouts/explorerscout.h"
std::optional<PlaylistScout::ScoutResult> PlaylistScout::scout(
const QUrl &url, const QStringList &allowedSuffixes) {
#ifdef Q_OS_WIN
// In Windows, Qt approach can not aware Windows Explorer custom sort order.
// So we need to try using Win32 functions for fetching it at first.
if (auto rv = PlaylistScout::Explorer::scout(url, allowedSuffixes); rv.has_value()) {
return rv;
}
#endif // Q_OS_WIN
// TODO: We may add macOS Finder, KDE Dolphin and etc supports in future.
// Qt way is the last fallback.
return PlaylistScout::Qt::scout(url, allowedSuffixes);
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2026 yyc12345 <yyc12321@outlook.com>
//
// SPDX-License-Identifier: MIT
#pragma once
#include <QUrl>
#include <QStringList>
#include <QList>
#include <optional>
namespace PlaylistScout {
/// The result of scout
struct ScoutResult {
/// The final fetched playlist (it can be empty).
/// Each item must be a QUrl pointing to a local file.
QList<QUrl> playlist;
/// The index of given file located in playlist.
/// Nullopt if it is not presented in playlist.
std::optional<qsizetype> indexHint;
};
/// @brief Scout available image files in given file's parent dir with given allowed suffixes.
/// @param[in] url The path to file whose parent dir is our target dir for finding image files.
/// @param[in] allowedSuffixes
/// Each items in this list is a wildcard pattern for matching allowed file name, like \c *.png .
/// The fetched item will be preserved if its name matching any of them.
/// If this list is empty, there is no restrictions for finding files.
/// @return Scout result if success, otherwise nullopt.
std::optional<ScoutResult> scout(const QUrl &url, const QStringList &allowedSuffixes);
}
+332
View File
@@ -0,0 +1,332 @@
// SPDX-FileCopyrightText: 2026 yyc12345 <yyc12321@outlook.com>
//
// SPDX-License-Identifier: MIT
#include "explorerscout.h"
#ifdef Q_OS_WIN
#include <QDir>
#include <QDebug>
#include <QFileInfo>
#include <QRegularExpression>
#include <optional>
#include <exdisp.h> // IShellWindows, CLSID_ShellWindows
#include <servprov.h> // IServiceProvider
#include <shlguid.h> // SID_STopLevelBrowser
#include <shlobj.h>
// YYC MARK:
// We use custom RAII wrappers instead of existing COM smart pointers because the
// project may be built with MinGW, which does not have good compatibility with
// CComPtr and Microsoft's WRL ComPtr (<wrl/client.h>).
// Furthermore, PIDLs are not COM objects (they are freed via ILFree, not Release),
// so no standard smart pointer covers them regardless of compiler.
/// RAII wrapper for COM initialization (CoInitialize / CoUninitialize).
/// Must be declared before any COM objects (e.g., ComPtr, UniquePidl) so that
/// CoUninitialize runs after all COM objects are released during scope exit.
class ComGuard {
public:
ComGuard() : m_hr(CoInitialize(nullptr)) {}
~ComGuard() { if (SUCCEEDED(m_hr)) CoUninitialize(); }
ComGuard(const ComGuard&) = delete;
ComGuard& operator=(const ComGuard&) = delete;
bool IsInitialized() const { return SUCCEEDED(m_hr); }
private:
HRESULT m_hr;
};
/// RAII wrapper for COM interface pointers.
template <typename T>
class ComPtr {
public:
ComPtr() = default;
ComPtr(std::nullptr_t) {}
~ComPtr() { Reset(); }
ComPtr(const ComPtr&) = delete;
ComPtr& operator=(const ComPtr&) = delete;
ComPtr(ComPtr&& other) noexcept : m_ptr(other.m_ptr) { other.m_ptr = nullptr; }
ComPtr& operator=(ComPtr&& other) noexcept {
if (this != &other) {
Reset();
m_ptr = other.m_ptr;
other.m_ptr = nullptr;
}
return *this;
}
T* operator->() const { return m_ptr; }
T* Get() const { return m_ptr; }
T** GetAddressOf() { return &m_ptr; }
explicit operator bool() const { return m_ptr != nullptr; }
void Reset() { if (m_ptr) { m_ptr->Release(); m_ptr = nullptr; } }
private:
T* m_ptr = nullptr;
};
/// RAII wrapper for PIDL (Item ID List).
class UniquePidl {
public:
UniquePidl() = default;
explicit UniquePidl(LPITEMIDLIST pidl) : m_pidl(pidl) {}
~UniquePidl() { Reset(); }
UniquePidl(const UniquePidl&) = delete;
UniquePidl& operator=(const UniquePidl&) = delete;
UniquePidl(UniquePidl&& other) noexcept : m_pidl(other.m_pidl) { other.m_pidl = nullptr; }
UniquePidl& operator=(UniquePidl&& other) noexcept {
if (this != &other) {
Reset();
m_pidl = other.m_pidl;
other.m_pidl = nullptr;
}
return *this;
}
LPCITEMIDLIST Get() const { return m_pidl; }
LPITEMIDLIST* GetAddressOf() { return &m_pidl; }
explicit operator bool() const { return m_pidl != nullptr; }
void Reset() { if (m_pidl) { ILFree(m_pidl); m_pidl = nullptr; } }
private:
LPITEMIDLIST m_pidl = nullptr;
};
/// Outputs a diagnostic warning for debugging Windows Explorer integration failures.
static void ReportError(const QString& detail)
{
qWarning("Windows Explorer file list fetch error: %s",
qPrintable(detail));
}
/// Normalizes a Windows directory path for case-insensitive comparison:
/// converts to native separators, lowercases, and ensures a trailing backslash.
static QString NormalizePath(const QString& path)
{
QString result = QDir::toNativeSeparators(path).toLower();
if (!result.endsWith(QChar('\\'))) {
result += QChar('\\');
}
return result;
}
/// Checks whether the Explorer window represented by the given IDispatch is
/// displaying the target folder. Returns its IFolderView2 on match, or null.
static ComPtr<IFolderView2> TryMatchWindow(IDispatch* dispatch, const QString& normalizedTarget)
{
// Navigate: IDispatch -> IServiceProvider -> IShellBrowser.
ComPtr<IServiceProvider> serviceProvider;
if (FAILED(dispatch->QueryInterface(IID_PPV_ARGS(serviceProvider.GetAddressOf())))) {
return nullptr;
}
ComPtr<IShellBrowser> shellBrowser;
if (FAILED(serviceProvider->QueryService(SID_STopLevelBrowser,
IID_PPV_ARGS(shellBrowser.GetAddressOf())))) {
return nullptr;
}
// IShellBrowser -> active IShellView.
ComPtr<IShellView> shellView;
if (FAILED(shellBrowser->QueryActiveShellView(shellView.GetAddressOf()))) {
return nullptr;
}
// Retrieve the folder's path via IPersistIDList.
ComPtr<IPersistIDList> persistIdList;
if (FAILED(shellView->QueryInterface(IID_PPV_ARGS(persistIdList.GetAddressOf())))) {
return nullptr;
}
UniquePidl folderPidl;
if (FAILED(persistIdList->GetIDList(folderPidl.GetAddressOf()))) {
return nullptr;
}
PWSTR pathStr = nullptr;
if (FAILED(SHGetNameFromIDList(folderPidl.Get(), SIGDN_DESKTOPABSOLUTEPARSING, &pathStr))) {
return nullptr;
}
QString windowPath = NormalizePath(QString::fromWCharArray(pathStr));
CoTaskMemFree(pathStr);
if (windowPath != normalizedTarget) {
return nullptr;
}
// Path matches — query IFolderView2 from the ShellView.
ComPtr<IFolderView2> folderView2;
if (FAILED(shellView->QueryInterface(IID_PPV_ARGS(folderView2.GetAddressOf())))) {
return nullptr;
}
return folderView2;
}
/// Tries to find a live Explorer window that is currently displaying the given
/// folder path. Returns the IFolderView2 from that window, or an empty ComPtr.
///
/// Explorer's sort order is stored only in the running Explorer process's memory.
/// Creating a new shell view via IShellFolder::CreateViewObject yields a default
/// sort (always by name), so we must find the actual live IFolderView2 instead.
///
/// Reference:
/// - https://www.stephenli.ca/getting-windows-explorers-current-directory-sort-order
/// - https://github.com/d2phap/ImageGlass/blob/75a296287f338edcf409eee3917531b25ff5d927/v9/Components/ImageGlass.Base/FileSystem/FileFinder.cs
/// - NuGet Package: D2Phap.EggShell
static ComPtr<IFolderView2> FindLiveFolderView(const QString& targetFolderPath)
{
// Create the Shell windows collection (all open Explorer windows).
ComPtr<IShellWindows> shellWindows;
if (FAILED(CoCreateInstance(CLSID_ShellWindows, nullptr, CLSCTX_ALL,
IID_PPV_ARGS(shellWindows.GetAddressOf())))) {
ReportError("Failed to create IShellWindows instance");
return nullptr;
}
QString normalizedTarget = NormalizePath(targetFolderPath);
// 1. Try the Desktop window first — it may contain the file the user opened.
{
VARIANT varLoc;
VariantInit(&varLoc);
varLoc.vt = VT_I4;
varLoc.lVal = 0;
VARIANT varLocRoot;
VariantInit(&varLocRoot);
long desktopHwnd = 0;
ComPtr<IDispatch> desktopDispatch;
HRESULT findHr = shellWindows->FindWindowSW(
&varLoc, &varLocRoot, SWC_DESKTOP,
&desktopHwnd, SWFO_NEEDDISPATCH,
desktopDispatch.GetAddressOf());
VariantClear(&varLoc);
VariantClear(&varLocRoot);
if (SUCCEEDED(findHr) && desktopDispatch) {
ComPtr<IFolderView2> result = TryMatchWindow(desktopDispatch.Get(), normalizedTarget);
if (result) return result;
}
}
// 2. Iterate regular Explorer windows.
LONG count = 0;
shellWindows->get_Count(&count);
for (LONG i = 0; i < count; i++) {
VARIANT varIndex;
VariantInit(&varIndex);
varIndex.vt = VT_I4;
varIndex.lVal = i;
ComPtr<IDispatch> dispatch;
HRESULT itemHr = shellWindows->Item(varIndex, dispatch.GetAddressOf());
VariantClear(&varIndex);
if (FAILED(itemHr) || !dispatch) {
continue;
}
ComPtr<IFolderView2> result = TryMatchWindow(dispatch.Get(), normalizedTarget);
if (result) return result;
}
return nullptr;
}
std::optional<PlaylistScout::ScoutResult> PlaylistScout::Explorer::scout(
const QUrl &url, const QStringList &allowedSuffixes) {
PlaylistScout::ScoutResult rv;
QFileInfo info(url.toLocalFile());
QString folderPath = info.path();
QString currentFileName = info.fileName();
// Pre-compile wildcard suffix filters into regular expressions for performance.
QList<QRegularExpression> nameRegexps;
for (const QString &pattern : allowedSuffixes) {
nameRegexps.append(QRegularExpression(
QRegularExpression::wildcardToRegularExpression(pattern),
QRegularExpression::CaseInsensitiveOption));
}
// Initialize COM. The ComGuard must be declared before any COM objects
// below so that CoUninitialize runs after all of them are released.
ComGuard comGuard;
if (!comGuard.IsInitialized()) {
ReportError("COM initialization failed");
return std::nullopt;
}
// Find a live Explorer window that is currently displaying the target folder.
ComPtr<IFolderView2> folderView2 = FindLiveFolderView(folderPath);
if (!folderView2) {
ReportError("No open Explorer window matches the folder: " + folderPath);
return std::nullopt;
}
// Get items already sorted in Explorer's view order, as an IShellItemArray.
// SVGIO_ALLVIEW returns all items; SVGIO_FLAG_VIEWORDER respects the view's sort.
ComPtr<IShellItemArray> itemArray;
if (FAILED(folderView2->Items(SVGIO_ALLVIEW | SVGIO_FLAG_VIEWORDER,
IID_PPV_ARGS(itemArray.GetAddressOf())))) {
ReportError("IFolderView2::Items(SVGIO_ALLVIEW | SVGIO_FLAG_VIEWORDER) failed");
return std::nullopt;
}
DWORD itemCount = 0;
itemArray->GetCount(&itemCount);
// Enumerate sorted items, filter by pattern, and build the playlist.
for (DWORD i = 0; i < itemCount; i++) {
ComPtr<IShellItem> shellItem;
if (FAILED(itemArray->GetItemAt(i, shellItem.GetAddressOf()))) {
continue;
}
// Get the full parsing path of the item.
PWSTR pathStr = nullptr;
if (FAILED(shellItem->GetDisplayName(SIGDN_DESKTOPABSOLUTEPARSING, &pathStr)) || !pathStr) {
continue;
}
QString filePath = QString::fromWCharArray(pathStr);
CoTaskMemFree(pathStr);
// Skip directories — the view may include folders alongside files.
QFileInfo fileInfo(filePath);
if (!fileInfo.isFile()) {
continue;
}
QString fileName = fileInfo.fileName();
// Match against compiled wildcard patterns.
bool matches = nameRegexps.isEmpty();
for (const QRegularExpression &re : std::as_const(nameRegexps)) {
if (re.match(fileName).hasMatch()) {
matches = true;
break;
}
}
if (!matches) {
continue;
}
rv.playlist.append(QUrl::fromLocalFile(filePath));
if (fileName == currentFileName) {
rv.indexHint = rv.playlist.size() - 1;
}
}
return rv;
}
#endif // Q_OS_WIN
+16
View File
@@ -0,0 +1,16 @@
// SPDX-FileCopyrightText: 2026 yyc12345 <yyc12321@outlook.com>
//
// SPDX-License-Identifier: MIT
#pragma once
#include "../playlistscout.h"
#include <QtSystemDetection>
#ifdef Q_OS_WIN
namespace PlaylistScout::Explorer {
std::optional<ScoutResult> scout(const QUrl &url, const QStringList &allowedSuffixes);
}
#endif // Q_OS_WIN
+40
View File
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2025 Gary Wang <git@blumia.net>
// SPDX-FileCopyrightText: 2026 yyc12345 <yyc12321@outlook.com>
//
// SPDX-License-Identifier: MIT
#include "qtscout.h"
#include <QFileInfo>
#include <QDir>
#include <QCollator>
std::optional<PlaylistScout::ScoutResult> PlaylistScout::Qt::scout(
const QUrl &url, const QStringList &allowedSuffixes) {
PlaylistScout::ScoutResult rv;
QFileInfo info(url.toLocalFile());
QDir dir(info.path());
QString currentFileName = info.fileName();
QStringList entryList = dir.entryList(
allowedSuffixes, QDir::Files | QDir::NoSymLinks, QDir::NoSort);
QCollator collator;
collator.setNumericMode(true);
std::sort(entryList.begin(), entryList.end(), collator);
for (qsizetype i = 0; i < entryList.count(); i++) {
const QString &fileName = entryList.at(i);
const QString &oneEntry = dir.absoluteFilePath(fileName);
const QUrl &url = QUrl::fromLocalFile(oneEntry);
rv.playlist.append(url);
if (fileName == currentFileName) {
rv.indexHint = i;
}
}
return rv;
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2026 yyc12345 <yyc12321@outlook.com>
//
// SPDX-License-Identifier: MIT
#pragma once
#include "../playlistscout.h"
namespace PlaylistScout::Qt {
std::optional<ScoutResult> scout(const QUrl &url, const QStringList &allowedSuffixes);
}
-12
View File
@@ -65,12 +65,6 @@ bool Settings::svgTiny12Only() const
#endif // QT_VERSION < QT_VERSION_CHECK(6, 9, 3)
}
Settings::DoubleClickBehavior Settings::doubleClickBehavior() const
{
QString result = m_qsettings->value("double_click_behavior", "").toString();
return QEnumHelper::fromString<DoubleClickBehavior>(result, DoubleClickBehavior::Ignore);
}
Settings::MouseWheelBehavior Settings::mouseWheelBehavior() const
{
QString result = m_qsettings->value("mouse_wheel_behavior", "").toString();
@@ -107,12 +101,6 @@ void Settings::setSvgTiny12Only(bool on)
m_qsettings->sync();
}
void Settings::setDoubleClickBehavior(DoubleClickBehavior dcb)
{
m_qsettings->setValue("double_click_behavior", QEnumHelper::toString(dcb));
m_qsettings->sync();
}
void Settings::setMouseWheelBehavior(MouseWheelBehavior mwb)
{
m_qsettings->setValue("mouse_wheel_behavior", QEnumHelper::toString(mwb));
-12
View File
@@ -11,23 +11,13 @@ class Settings : public QObject
{
Q_OBJECT
public:
enum DoubleClickBehavior {
Ignore,
Close,
Maximize,
FullScreen,
};
Q_ENUM(DoubleClickBehavior)
enum MouseWheelBehavior {
Zoom,
Switch,
};
Q_ENUM(MouseWheelBehavior)
// TODO: I want to remove Auto behavior in future.
enum WindowSizeBehavior {
Auto,
Maximized,
Windowed,
};
@@ -38,7 +28,6 @@ public:
bool useLightCheckerboard() const;
bool loopGallery() const;
bool svgTiny12Only() const;
DoubleClickBehavior doubleClickBehavior() const;
MouseWheelBehavior mouseWheelBehavior() const;
WindowSizeBehavior initWindowSizeBehavior() const;
Qt::HighDpiScaleFactorRoundingPolicy hiDpiScaleFactorBehavior() const;
@@ -46,7 +35,6 @@ public:
void setUseLightCheckerboard(bool light);
void setLoopGallery(bool on);
void setSvgTiny12Only(bool on);
void setDoubleClickBehavior(DoubleClickBehavior dcb);
void setMouseWheelBehavior(MouseWheelBehavior mwb);
void setInitWindowSizeBehavior(WindowSizeBehavior wsb);
void setHiDpiScaleFactorBehavior(Qt::HighDpiScaleFactorRoundingPolicy hidpi);
-17
View File
@@ -49,15 +49,6 @@ private:
Pairs pairs;
};
static EnumComboBoxTransformer<Settings::DoubleClickBehavior> DC_OPTIONS {
{
{Settings::DoubleClickBehavior::Ignore, QCoreApplication::translate("SettingsDialog", "Do nothing")},
{Settings::DoubleClickBehavior::Close, QCoreApplication::translate("SettingsDialog", "Close the window")},
{Settings::DoubleClickBehavior::Maximize, QCoreApplication::translate("SettingsDialog", "Toggle maximize")},
{Settings::DoubleClickBehavior::FullScreen, QCoreApplication::translate("SettingsDialog", "Toggle fullscreen")}
}
};
static EnumComboBoxTransformer<Settings::MouseWheelBehavior, QString> MW_OPTIONS {
{
{ Settings::MouseWheelBehavior::Zoom, QCoreApplication::translate("SettingsDialog", "Zoom in and out") },
@@ -67,7 +58,6 @@ static EnumComboBoxTransformer<Settings::MouseWheelBehavior, QString> MW_OPTIONS
static EnumComboBoxTransformer<Settings::WindowSizeBehavior, QString> IWS_OPTIONS {
{
{ Settings::WindowSizeBehavior::Auto, QCoreApplication::translate("SettingsDialog", "Auto size") },
{ Settings::WindowSizeBehavior::Maximized, QCoreApplication::translate("SettingsDialog", "Maximized") },
{ Settings::WindowSizeBehavior::Windowed, QCoreApplication::translate("SettingsDialog", "Windowed") }
}
@@ -92,9 +82,6 @@ SettingsDialog::SettingsDialog(QWidget *parent)
ui->m_useLightCheckerboard->setChecked(settings->useLightCheckerboard());
ui->m_loopGallery->setChecked(settings->loopGallery());
ui->m_svgTiny12Only->setChecked(settings->svgTiny12Only());
ui->m_doubleClickBehavior->setModel(DC_OPTIONS.buildComboBoxModel(this));
Settings::DoubleClickBehavior dcb = settings->doubleClickBehavior();
ui->m_doubleClickBehavior->setCurrentIndex(DC_OPTIONS.to_index(dcb));
ui->m_mouseWheelBehavior->setModel(MW_OPTIONS.buildComboBoxModel(this));
Settings::MouseWheelBehavior mwb = settings->mouseWheelBehavior();
ui->m_mouseWheelBehavior->setCurrentIndex(MW_OPTIONS.to_index(mwb));
@@ -125,10 +112,6 @@ SettingsDialog::SettingsDialog(QWidget *parent)
Settings::instance()->setSvgTiny12Only(state == Qt::Checked);
});
connect(ui->m_doubleClickBehavior, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [ = ](int index){
Settings::instance()->setDoubleClickBehavior(DC_OPTIONS.to_value(index));
});
connect(ui->m_mouseWheelBehavior, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [ = ](int index){
Settings::instance()->setMouseWheelBehavior(MW_OPTIONS.to_value(index));
});
+5 -15
View File
@@ -41,43 +41,33 @@
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="m_doubleClickBehaviorLabel">
<property name="text">
<string>Double-click behavior</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QComboBox" name="m_doubleClickBehavior"/>
</item>
<item row="4" column="0">
<widget class="QLabel" name="m_mouseWheelBehaviorLabel">
<property name="text">
<string>Mouse wheel behavior</string>
</property>
</widget>
</item>
<item row="4" column="1">
<item row="3" column="1">
<widget class="QComboBox" name="m_mouseWheelBehavior"/>
</item>
<item row="5" column="0">
<item row="4" column="0">
<widget class="QLabel" name="m_initWindowSizeBehaviorLabel">
<property name="text">
<string>Default window size</string>
</property>
</widget>
</item>
<item row="5" column="1">
<item row="4" column="1">
<widget class="QComboBox" name="m_initWindowSizeBehavior"/>
</item>
<item row="6" column="0">
<item row="5" column="0">
<widget class="QLabel" name="m_hiDpiRoundingPolicyBehaviorLabel">
<property name="text">
<string>HiDPI scale factor rounding policy</string>
</property>
</widget>
</item>
<item row="6" column="1">
<item row="5" column="1">
<widget class="QComboBox" name="m_hiDpiRoundingPolicyBehavior"/>
</item>
</layout>