diff --git a/model/placesmodel.cpp b/model/placesmodel.cpp index 9a00028..6c1253f 100644 --- a/model/placesmodel.cpp +++ b/model/placesmodel.cpp @@ -22,6 +22,8 @@ #include #include #include +#include +#include #include #include @@ -83,9 +85,33 @@ PlacesModel::PlacesModel(QObject *parent) m_items.append(item); } - PlacesItem *trashItem = new PlacesItem(tr("Trash"), QUrl(QStringLiteral("trash:///"))); - trashItem->setIconName("user-trash"); - m_items.append(trashItem); + QSettings settings(QStringLiteral("cutefish"), QStringLiteral("filemanager-sidebar")); + if (settings.contains(QStringLiteral("favorites"))) { + const auto defaults = m_items; + m_items.clear(); + const QStringList saved = settings.value(QStringLiteral("favorites")).toStringList(); + for (const QString &entry : saved) { + const QUrl url(entry); + if (!url.isLocalFile()) + continue; + PlacesItem *favorite = nullptr; + for (PlacesItem *item : defaults) { + if (item->url() == url) { + favorite = new PlacesItem(item->displayName(), url); + favorite->setIconName(item->iconName()); + break; + } + } + if (!favorite) { + favorite = new PlacesItem(QFileInfo(url.toLocalFile()).fileName(), url); + favorite->setIconName(QStringLiteral("folder")); + } + m_items.append(favorite); + } + qDeleteAll(defaults); + } + for (PlacesItem *item : m_items) + item->setCategory(tr("Favorites")); QString predicateStr( QString::fromLatin1("[[[[ StorageVolume.ignored == false AND [ StorageVolume.usage == 'FileSystem' OR StorageVolume.usage == 'Encrypted' ]]" @@ -106,10 +132,15 @@ PlacesModel::PlacesModel(QObject *parent) for (const Solid::Device &device : deviceList) { PlacesItem *deviceItem = new PlacesItem; deviceItem->setUdi(device.udi()); - deviceItem->setCategory(tr("Drives")); + deviceItem->setCategory(tr("Locations")); m_items.append(deviceItem); } + PlacesItem *trashItem = new PlacesItem(tr("Trash"), QUrl(QStringLiteral("trash:///"))); + trashItem->setCategory(tr("Locations")); + trashItem->setIconName("user-trash"); + m_items.append(trashItem); + // Init Signals for (PlacesItem *item : m_items) { connect(item, &PlacesItem::itemChanged, this, &PlacesModel::onItemChanged); @@ -118,6 +149,82 @@ PlacesModel::PlacesModel(QObject *parent) PlacesModel::~PlacesModel() { + qDeleteAll(m_items); +} + +int PlacesModel::favoriteCount() const +{ + int count = 0; + while (count < m_items.size() && m_items.at(count)->category() == tr("Favorites")) + ++count; + return count; +} + +bool PlacesModel::canAddFavorites(const QList &urls) const +{ + if (urls.isEmpty()) + return false; + for (const QUrl &url : urls) { + if (!url.isLocalFile() || !QFileInfo(url.toLocalFile()).isDir()) + return false; + } + return true; +} + +bool PlacesModel::addFavorites(const QList &urls, int before) +{ + if (before < 0 || before > favoriteCount() || !canAddFavorites(urls)) + return false; + for (const QUrl &input : urls) { + const QUrl url = QUrl::fromLocalFile(QDir::cleanPath(input.toLocalFile())); + bool duplicate = false; + for (int row = 0; row < favoriteCount(); ++row) + duplicate |= m_items.at(row)->url() == url; + if (duplicate) + continue; + auto *item = new PlacesItem(QFileInfo(url.toLocalFile()).fileName(), url); + item->setIconName(QStringLiteral("folder")); + item->setCategory(tr("Favorites")); + beginInsertRows(QModelIndex(), before, before); + m_items.insert(before++, item); + endInsertRows(); + } + saveFavorites(); + return true; +} + +bool PlacesModel::moveFavorite(int from, int before) +{ + const int count = favoriteCount(); + if (from < 0 || from >= count || before < 0 || before > count) + return false; + if (before == from || before == from + 1) + return true; + beginMoveRows(QModelIndex(), from, from, QModelIndex(), before); + m_items.move(from, before > from ? before - 1 : before); + endMoveRows(); + saveFavorites(); + return true; +} + +void PlacesModel::removeFavorite(int row) +{ + if (row < 0 || row >= favoriteCount()) + return; + beginRemoveRows(QModelIndex(), row, row); + delete m_items.takeAt(row); + endRemoveRows(); + saveFavorites(); +} + +void PlacesModel::saveFavorites() +{ + QStringList urls; + for (int row = 0; row < favoriteCount(); ++row) + urls.append(m_items.at(row)->url().toString()); + QSettings settings(QStringLiteral("cutefish"), QStringLiteral("filemanager-sidebar")); + settings.setValue(QStringLiteral("favorites"), urls); + emit favoritesChanged(); } QHash PlacesModel::roleNames() const @@ -273,7 +380,7 @@ void PlacesModel::onDeviceAdded(const QString &udi) beginInsertRows(QModelIndex(), rowCount(), rowCount()); PlacesItem *deviceItem = new PlacesItem; deviceItem->setUdi(udi); - deviceItem->setCategory(tr("Drives")); + deviceItem->setCategory(tr("Locations")); m_items.append(deviceItem); endInsertRows(); diff --git a/model/placesmodel.h b/model/placesmodel.h index 37dcdce..fc9b32f 100644 --- a/model/placesmodel.h +++ b/model/placesmodel.h @@ -27,6 +27,7 @@ class PlacesModel : public QAbstractItemModel { Q_OBJECT + Q_PROPERTY(int favoriteCount READ favoriteCount NOTIFY favoritesChanged) public: enum DataRole { @@ -54,11 +55,17 @@ public: QModelIndex parent(const QModelIndex &child) const override; Q_INVOKABLE QVariantMap get(const int &index) const; + int favoriteCount() const; + Q_INVOKABLE bool canAddFavorites(const QList &urls) const; + Q_INVOKABLE bool addFavorites(const QList &urls, int before); + Q_INVOKABLE bool moveFavorite(int from, int before); + Q_INVOKABLE void removeFavorite(int row); Q_INVOKABLE void requestSetup(const int &index); Q_INVOKABLE void requestEject(const int &index); Q_INVOKABLE void requestTeardown(const int &index); signals: + void favoritesChanged(); void deviceSetupDone(const QString &filePath); private slots: @@ -67,6 +74,7 @@ private slots: void onItemChanged(PlacesItem *); private: + void saveFavorites(); QList m_items; Solid::Predicate m_predicate; }; diff --git a/qml/SideBar.qml b/qml/SideBar.qml index e0872d1..30264b6 100644 --- a/qml/SideBar.qml +++ b/qml/SideBar.qml @@ -18,6 +18,7 @@ */ import QtQuick 2.12 +import QtQuick 2.12 as Quick import QtQuick.Layouts 1.12 import QtQuick.Controls 2.12 import QtQuick.Window 2.12 @@ -36,6 +37,10 @@ Item { property alias currentIndex: listView.currentIndex property alias count: listView.count property alias model: listView.model + property string selectedPath + property int dropIndex: -1 + property real dropY: 0 + property real pointerY: 0 signal clicked(string path) signal openInNewWindow(string path) @@ -46,6 +51,7 @@ Item { PlacesModel { id: placesModel + onFavoritesChanged: sideBar.updateSelection(sideBar.selectedPath) onDeviceSetupDone: sideBar.clicked(filePath) // 设备挂载上后,模拟点击了该设备以打开该页面 } @@ -71,7 +77,11 @@ Item { id: titleLabel text: sideBar.title color: root.active ? FishUI.Theme.textColor : FishUI.Theme.disabledTextColor + Layout.fillWidth: true Layout.preferredHeight: root.header.height + Layout.minimumHeight: root.header.height + Layout.maximumHeight: root.header.height + elide: Text.ElideRight leftPadding: FishUI.Units.largeSpacing + FishUI.Units.smallSpacing rightPadding: FishUI.Units.largeSpacing + FishUI.Units.smallSpacing topPadding: FishUI.Units.smallSpacing @@ -86,6 +96,16 @@ Item { Layout.topMargin: FishUI.Units.smallSpacing clip: true model: placesModel + header: Label { + width: listView.width + height: listView.snap(32) + leftPadding: listView.sideInset + FishUI.Units.smallSpacing + verticalAlignment: Text.AlignVCenter + text: qsTr("Favorites") + color: FishUI.Theme.disabledTextColor + font.pointSize: 9 + font.bold: true + } // Rounding to whole *logical* pixels is not enough at a fractional scale: // at 150% a 37px row pitch is 55.5 device px, so every other row lands on @@ -105,38 +125,33 @@ Item { readonly property real sideInset: listView.snap(FishUI.Units.smallSpacing * 1.5) bottomMargin: listView.snap(FishUI.Units.smallSpacing) - spacing: 2 + spacing: listView.snap(3) ScrollBar.vertical: ScrollBar { bottomPadding: FishUI.Units.smallSpacing } section.property: "category" - section.delegate: Item { + section.delegate: Label { width: ListView.view.width - height: listView.snap(FishUI.Units.fontMetrics.height + FishUI.Units.largeSpacing + FishUI.Units.smallSpacing) - - Text { - anchors.left: parent.left - anchors.top: parent.top - anchors.leftMargin: Qt.application.layoutDirection === Qt.RightToLeft - ? 0 : listView.snap(listView.sideInset + FishUI.Units.smallSpacing) - anchors.rightMargin: FishUI.Units.smallSpacing - anchors.topMargin: FishUI.Units.largeSpacing - anchors.bottomMargin: FishUI.Units.smallSpacing - color: FishUI.Theme.textColor - font.pointSize: 9 - font.bold: true - text: section - } + height: section === qsTr("Favorites") ? 0 : listView.snap(36) + // ListView controls delegate visibility, including zero-height sections. + text: section === qsTr("Favorites") ? "" : section + leftPadding: listView.snap(listView.sideInset + FishUI.Units.smallSpacing) + rightPadding: FishUI.Units.smallSpacing + verticalAlignment: Text.AlignVCenter + color: FishUI.Theme.disabledTextColor + font.pointSize: 9 + font.bold: true } delegate: Item { id: _item width: ListView.view.width - height: listView.snap(FishUI.Units.fontMetrics.height + FishUI.Units.largeSpacing * 1.5) + height: listView.snap(Math.max(34, FishUI.Units.fontMetrics.height + 12)) property bool checked: sideBar.currentIndex === index + opacity: _mouseArea.drag.active ? 0.45 : 1 property color hoveredColor: FishUI.Theme.darkMode ? Qt.lighter(FishUI.Theme.backgroundColor, 1.1) : Qt.darker(FishUI.Theme.backgroundColor, 1.1) MouseArea { @@ -144,6 +159,28 @@ Item { anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton + drag.target: index < placesModel.favoriteCount ? dragProxy : null + drag.axis: Drag.YAxis + onPressed: function(mouse) { + if (mouse.button === Qt.LeftButton) { + dragProxy.favoriteRow = index + dragProxy.x = mouse.x + dragProxy.y = _item.mapToItem(sideBar, mouse.x, mouse.y).y + } + } + onReleased: { + if (drag.active) + dragProxy.Drag.drop() + sideBar.dropIndex = -1 + } + onCanceled: { + dragProxy.Drag.cancel() + sideBar.dropIndex = -1 + } + onPositionChanged: function(mouse) { + if (drag.active) + dragProxy.y = _item.mapToItem(sideBar, mouse.x, mouse.y).y + } onClicked: function(mouse) { if (mouse.button === Qt.LeftButton) { if (model.isDevice && model.setupNeeded) @@ -156,6 +193,13 @@ Item { } } + Connections { + target: _mouseArea.drag + function onActiveChanged() { + dragProxy.dragging = _mouseArea.drag.active + } + } + FishUI.DesktopMenu { id: _menu @@ -178,6 +222,16 @@ Item { } } + FishUI.MenuSeparator { + visible: index < placesModel.favoriteCount + } + + FishUI.MenuItem { + text: qsTr("Remove from Favorites") + visible: index < placesModel.favoriteCount + onTriggered: placesModel.removeFavorite(index) + } + FishUI.MenuSeparator { Layout.fillWidth: true visible: _ejectMenuItem.visible || _umountMenuItem.visible @@ -228,14 +282,12 @@ Item { smooth: true } - // Positioned by hand rather than with a RowLayout: AlignVCenter computes - // (height - 22) / 2, which lands on a half device pixel even when the - // row height itself is snapped. + // Snap icon coordinates as well as size for fractional display scales. FishUI.IconItem { id: _icon x: listView.snap(listView.sideInset + FishUI.Units.smallSpacing) y: listView.snap((_item.height - height) / 2) - width: listView.snap(22) + width: listView.snap(20) height: width source: model.iconName @@ -255,7 +307,103 @@ Item { } } + Item { + id: dragProxy + width: 1 + height: 1 + property int favoriteRow: -1 + property bool dragging: false + Drag.active: dragging + Drag.source: dragProxy + Drag.supportedActions: Qt.MoveAction + } + + Quick.DropArea { + id: favoritesDrop + anchors.fill: parent + z: 10 + property bool internalDrag: false + onEntered: function(drag) { + internalDrag = drag.source === dragProxy + drag.accepted = internalDrag || (drag.hasUrls && placesModel.canAddFavorites(drag.urls)) + if (drag.accepted) + sideBar.positionDrop(drag.y) + } + onPositionChanged: function(drag) { + sideBar.positionDrop(drag.y) + } + onExited: sideBar.dropIndex = -1 + onDropped: function(drop) { + if (sideBar.dropIndex < 0) + return + var accepted = internalDrag + ? placesModel.moveFavorite(dragProxy.favoriteRow, sideBar.dropIndex) + : placesModel.addFavorites(drop.urls, sideBar.dropIndex) + if (accepted) + drop.accept(internalDrag ? Qt.MoveAction : Qt.CopyAction) + sideBar.dropIndex = -1 + } + } + + Rectangle { + z: 11 + visible: sideBar.dropIndex >= 0 + x: listView.sideInset + FishUI.Units.smallSpacing + y: sideBar.dropY - height / 2 + width: sideBar.width - x - listView.sideInset + height: listView.snap(2) + color: FishUI.Theme.highlightColor + radius: height / 2 + Rectangle { + x: -3 + anchors.verticalCenter: parent.verticalCenter + width: 6 + height: 6 + radius: 3 + color: FishUI.Theme.highlightColor + } + } + + Timer { + interval: 30 + repeat: true + running: favoritesDrop.containsDrag + onTriggered: { + var localY = sideBar.pointerY - listView.y + var delta = localY < 32 ? -6 : localY > listView.height - 32 ? 6 : 0 + if (delta !== 0) { + var minimum = listView.originY + var maximum = Math.max(minimum, listView.contentHeight - listView.height + listView.originY) + listView.contentY = Math.max(minimum, Math.min(maximum, listView.contentY + delta)) + sideBar.positionDrop(sideBar.pointerY) + } + } + } + + function positionDrop(y) { + pointerY = y + var count = placesModel.favoriteCount + var contentY = y - listView.y + listView.contentY + var boundary = listView.headerItem.y + listView.headerItem.height + dropIndex = count + for (var i = 0; i < count; ++i) { + var item = listView.itemAtIndex(i) + if (!item) + continue + if (contentY < item.y + item.height / 2) { + dropIndex = i + boundary = item.y + break + } + boundary = item.y + item.height + } + dropY = listView.y + boundary - listView.contentY + if (contentY > boundary + 24 || dropY < listView.y || dropY > listView.y + listView.height) + dropIndex = -1 + } + function updateSelection(path) { + selectedPath = path listView.currentIndex = -1 for (var i = 0; i < listView.count; ++i) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..e45ed9d --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.14) +project(sidebar-tests LANGUAGES CXX) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 REQUIRED COMPONENTS Core Test) +find_package(KF6Solid REQUIRED) +enable_testing() +add_executable(tst_placesmodel tst_placesmodel.cpp ../model/placesmodel.cpp ../model/placesitem.cpp) +target_link_libraries(tst_placesmodel PRIVATE Qt6::Core Qt6::Test KF6::Solid) +add_test(NAME placesmodel COMMAND tst_placesmodel) diff --git a/tests/tst_placesmodel.cpp b/tests/tst_placesmodel.cpp new file mode 100644 index 0000000..099d23b --- /dev/null +++ b/tests/tst_placesmodel.cpp @@ -0,0 +1,63 @@ +#include "../model/placesmodel.h" + +#include +#include +#include +#include +#include + +class PlacesModelTest : public QObject +{ + Q_OBJECT + +private slots: + void favorites() + { + QTemporaryDir config; + QTemporaryDir folders; + QVERIFY(config.isValid()); + QVERIFY(folders.isValid()); + qputenv("XDG_CONFIG_HOME", config.path().toUtf8()); + QSettings settings(QStringLiteral("cutefish"), QStringLiteral("filemanager-sidebar")); + settings.setValue(QStringLiteral("favorites"), QStringList()); + settings.sync(); + + QDir dir(folders.path()); + QVERIFY(dir.mkdir("first")); + QVERIFY(dir.mkdir("second")); + const QUrl first = QUrl::fromLocalFile(dir.filePath("first")); + const QUrl second = QUrl::fromLocalFile(dir.filePath("second")); + PlacesModel model; + QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest); + QCOMPARE(model.favoriteCount(), 0); + QVERIFY(!model.canAddFavorites({})); + QVERIFY(!model.addFavorites({QUrl("https://example.com")}, 0)); + QVERIFY(!model.addFavorites({first, QUrl::fromLocalFile(dir.filePath("missing"))}, 0)); + QCOMPARE(model.favoriteCount(), 0); + QVERIFY(model.addFavorites({first, second}, 0)); + QCOMPARE(model.favoriteCount(), 2); + QVERIFY(model.addFavorites({first}, 0)); + QCOMPARE(model.favoriteCount(), 2); + QVERIFY(!model.moveFavorite(2, 0)); + QVERIFY(!model.moveFavorite(0, 3)); + QVERIFY(model.moveFavorite(0, 2)); + QCOMPARE(model.get(0).value("url").toUrl(), second); + QVERIFY(model.moveFavorite(1, 0)); + QCOMPARE(model.get(0).value("url").toUrl(), first); + QVERIFY(model.moveFavorite(0, 1)); + model.removeFavorite(0); + QCOMPARE(model.favoriteCount(), 1); + QVERIFY(QFileInfo(first.toLocalFile()).isDir()); + { + PlacesModel restored; + QCOMPARE(restored.favoriteCount(), 1); + QCOMPARE(restored.get(0).value("url").toUrl(), second); + } + model.removeFavorite(0); + PlacesModel empty; + QCOMPARE(empty.favoriteCount(), 0); + } +}; + +QTEST_GUILESS_MAIN(PlacesModelTest) +#include "tst_placesmodel.moc"