feat(filemanager): add draggable sidebar favorites and unified locations

main
reionwong 3 weeks ago
parent f42dae8e60
commit 6da810db92

@ -22,6 +22,8 @@
#include <QStandardPaths> #include <QStandardPaths>
#include <QDir> #include <QDir>
#include <QDebug> #include <QDebug>
#include <QFileInfo>
#include <QSettings>
#include <Solid/Device> #include <Solid/Device>
#include <Solid/DeviceNotifier> #include <Solid/DeviceNotifier>
@ -83,9 +85,33 @@ PlacesModel::PlacesModel(QObject *parent)
m_items.append(item); m_items.append(item);
} }
PlacesItem *trashItem = new PlacesItem(tr("Trash"), QUrl(QStringLiteral("trash:///"))); QSettings settings(QStringLiteral("cutefish"), QStringLiteral("filemanager-sidebar"));
trashItem->setIconName("user-trash"); if (settings.contains(QStringLiteral("favorites"))) {
m_items.append(trashItem); 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 predicateStr(
QString::fromLatin1("[[[[ StorageVolume.ignored == false AND [ StorageVolume.usage == 'FileSystem' OR StorageVolume.usage == 'Encrypted' ]]" 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) { for (const Solid::Device &device : deviceList) {
PlacesItem *deviceItem = new PlacesItem; PlacesItem *deviceItem = new PlacesItem;
deviceItem->setUdi(device.udi()); deviceItem->setUdi(device.udi());
deviceItem->setCategory(tr("Drives")); deviceItem->setCategory(tr("Locations"));
m_items.append(deviceItem); 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 // Init Signals
for (PlacesItem *item : m_items) { for (PlacesItem *item : m_items) {
connect(item, &PlacesItem::itemChanged, this, &PlacesModel::onItemChanged); connect(item, &PlacesItem::itemChanged, this, &PlacesModel::onItemChanged);
@ -118,6 +149,82 @@ PlacesModel::PlacesModel(QObject *parent)
PlacesModel::~PlacesModel() 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<QUrl> &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<QUrl> &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<int, QByteArray> PlacesModel::roleNames() const QHash<int, QByteArray> PlacesModel::roleNames() const
@ -273,7 +380,7 @@ void PlacesModel::onDeviceAdded(const QString &udi)
beginInsertRows(QModelIndex(), rowCount(), rowCount()); beginInsertRows(QModelIndex(), rowCount(), rowCount());
PlacesItem *deviceItem = new PlacesItem; PlacesItem *deviceItem = new PlacesItem;
deviceItem->setUdi(udi); deviceItem->setUdi(udi);
deviceItem->setCategory(tr("Drives")); deviceItem->setCategory(tr("Locations"));
m_items.append(deviceItem); m_items.append(deviceItem);
endInsertRows(); endInsertRows();

@ -27,6 +27,7 @@
class PlacesModel : public QAbstractItemModel class PlacesModel : public QAbstractItemModel
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(int favoriteCount READ favoriteCount NOTIFY favoritesChanged)
public: public:
enum DataRole { enum DataRole {
@ -54,11 +55,17 @@ public:
QModelIndex parent(const QModelIndex &child) const override; QModelIndex parent(const QModelIndex &child) const override;
Q_INVOKABLE QVariantMap get(const int &index) const; Q_INVOKABLE QVariantMap get(const int &index) const;
int favoriteCount() const;
Q_INVOKABLE bool canAddFavorites(const QList<QUrl> &urls) const;
Q_INVOKABLE bool addFavorites(const QList<QUrl> &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 requestSetup(const int &index);
Q_INVOKABLE void requestEject(const int &index); Q_INVOKABLE void requestEject(const int &index);
Q_INVOKABLE void requestTeardown(const int &index); Q_INVOKABLE void requestTeardown(const int &index);
signals: signals:
void favoritesChanged();
void deviceSetupDone(const QString &filePath); void deviceSetupDone(const QString &filePath);
private slots: private slots:
@ -67,6 +74,7 @@ private slots:
void onItemChanged(PlacesItem *); void onItemChanged(PlacesItem *);
private: private:
void saveFavorites();
QList<PlacesItem *> m_items; QList<PlacesItem *> m_items;
Solid::Predicate m_predicate; Solid::Predicate m_predicate;
}; };

@ -18,6 +18,7 @@
*/ */
import QtQuick 2.12 import QtQuick 2.12
import QtQuick 2.12 as Quick
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import QtQuick.Controls 2.12 import QtQuick.Controls 2.12
import QtQuick.Window 2.12 import QtQuick.Window 2.12
@ -36,6 +37,10 @@ Item {
property alias currentIndex: listView.currentIndex property alias currentIndex: listView.currentIndex
property alias count: listView.count property alias count: listView.count
property alias model: listView.model 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 clicked(string path)
signal openInNewWindow(string path) signal openInNewWindow(string path)
@ -46,6 +51,7 @@ Item {
PlacesModel { PlacesModel {
id: placesModel id: placesModel
onFavoritesChanged: sideBar.updateSelection(sideBar.selectedPath)
onDeviceSetupDone: sideBar.clicked(filePath) // onDeviceSetupDone: sideBar.clicked(filePath) //
} }
@ -71,7 +77,11 @@ Item {
id: titleLabel id: titleLabel
text: sideBar.title text: sideBar.title
color: root.active ? FishUI.Theme.textColor : FishUI.Theme.disabledTextColor color: root.active ? FishUI.Theme.textColor : FishUI.Theme.disabledTextColor
Layout.fillWidth: true
Layout.preferredHeight: root.header.height 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 leftPadding: FishUI.Units.largeSpacing + FishUI.Units.smallSpacing
rightPadding: FishUI.Units.largeSpacing + FishUI.Units.smallSpacing rightPadding: FishUI.Units.largeSpacing + FishUI.Units.smallSpacing
topPadding: FishUI.Units.smallSpacing topPadding: FishUI.Units.smallSpacing
@ -86,6 +96,16 @@ Item {
Layout.topMargin: FishUI.Units.smallSpacing Layout.topMargin: FishUI.Units.smallSpacing
clip: true clip: true
model: placesModel 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: // 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 // 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) readonly property real sideInset: listView.snap(FishUI.Units.smallSpacing * 1.5)
bottomMargin: listView.snap(FishUI.Units.smallSpacing) bottomMargin: listView.snap(FishUI.Units.smallSpacing)
spacing: 2 spacing: listView.snap(3)
ScrollBar.vertical: ScrollBar { ScrollBar.vertical: ScrollBar {
bottomPadding: FishUI.Units.smallSpacing bottomPadding: FishUI.Units.smallSpacing
} }
section.property: "category" section.property: "category"
section.delegate: Item { section.delegate: Label {
width: ListView.view.width width: ListView.view.width
height: listView.snap(FishUI.Units.fontMetrics.height + FishUI.Units.largeSpacing + FishUI.Units.smallSpacing) height: section === qsTr("Favorites") ? 0 : listView.snap(36)
// ListView controls delegate visibility, including zero-height sections.
Text { text: section === qsTr("Favorites") ? "" : section
anchors.left: parent.left leftPadding: listView.snap(listView.sideInset + FishUI.Units.smallSpacing)
anchors.top: parent.top rightPadding: FishUI.Units.smallSpacing
anchors.leftMargin: Qt.application.layoutDirection === Qt.RightToLeft verticalAlignment: Text.AlignVCenter
? 0 : listView.snap(listView.sideInset + FishUI.Units.smallSpacing) color: FishUI.Theme.disabledTextColor
anchors.rightMargin: FishUI.Units.smallSpacing font.pointSize: 9
anchors.topMargin: FishUI.Units.largeSpacing font.bold: true
anchors.bottomMargin: FishUI.Units.smallSpacing
color: FishUI.Theme.textColor
font.pointSize: 9
font.bold: true
text: section
}
} }
delegate: Item { delegate: Item {
id: _item id: _item
width: ListView.view.width 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 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) property color hoveredColor: FishUI.Theme.darkMode ? Qt.lighter(FishUI.Theme.backgroundColor, 1.1)
: Qt.darker(FishUI.Theme.backgroundColor, 1.1) : Qt.darker(FishUI.Theme.backgroundColor, 1.1)
MouseArea { MouseArea {
@ -144,6 +159,28 @@ Item {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton 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) { onClicked: function(mouse) {
if (mouse.button === Qt.LeftButton) { if (mouse.button === Qt.LeftButton) {
if (model.isDevice && model.setupNeeded) if (model.isDevice && model.setupNeeded)
@ -156,6 +193,13 @@ Item {
} }
} }
Connections {
target: _mouseArea.drag
function onActiveChanged() {
dragProxy.dragging = _mouseArea.drag.active
}
}
FishUI.DesktopMenu { FishUI.DesktopMenu {
id: _menu 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 { FishUI.MenuSeparator {
Layout.fillWidth: true Layout.fillWidth: true
visible: _ejectMenuItem.visible || _umountMenuItem.visible visible: _ejectMenuItem.visible || _umountMenuItem.visible
@ -228,14 +282,12 @@ Item {
smooth: true smooth: true
} }
// Positioned by hand rather than with a RowLayout: AlignVCenter computes // Snap icon coordinates as well as size for fractional display scales.
// (height - 22) / 2, which lands on a half device pixel even when the
// row height itself is snapped.
FishUI.IconItem { FishUI.IconItem {
id: _icon id: _icon
x: listView.snap(listView.sideInset + FishUI.Units.smallSpacing) x: listView.snap(listView.sideInset + FishUI.Units.smallSpacing)
y: listView.snap((_item.height - height) / 2) y: listView.snap((_item.height - height) / 2)
width: listView.snap(22) width: listView.snap(20)
height: width height: width
source: model.iconName 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) { function updateSelection(path) {
selectedPath = path
listView.currentIndex = -1 listView.currentIndex = -1
for (var i = 0; i < listView.count; ++i) { for (var i = 0; i < listView.count; ++i) {

@ -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)

@ -0,0 +1,63 @@
#include "../model/placesmodel.h"
#include <QAbstractItemModelTester>
#include <QDir>
#include <QSettings>
#include <QTemporaryDir>
#include <QtTest>
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"
Loading…
Cancel
Save