feat(filemanager): add reorderable folder tabs and stable window menus
parent
6da810db92
commit
cc2b984d41
@ -0,0 +1,258 @@
|
||||
import QtQuick 2.12
|
||||
import QtQuick.Controls 2.12
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import FishUI 1.0 as FishUI
|
||||
|
||||
Rectangle {
|
||||
id: control
|
||||
property var tabs: []
|
||||
property int currentIndex: 0
|
||||
property int dragIndex: -1
|
||||
property int dropIndex: -1
|
||||
property real pointerX: 0
|
||||
property string dragTitle: ""
|
||||
signal selected(int index)
|
||||
signal closeRequested(int index)
|
||||
signal newRequested()
|
||||
signal duplicateRequested(string path)
|
||||
signal moveRequested(int from, int to)
|
||||
|
||||
color: FishUI.Theme.secondBackgroundColor
|
||||
readonly property color separatorColor: Qt.rgba(FishUI.Theme.textColor.r,
|
||||
FishUI.Theme.textColor.g, FishUI.Theme.textColor.b, 0.10)
|
||||
readonly property color hoverColor: Qt.rgba(FishUI.Theme.textColor.r,
|
||||
FishUI.Theme.textColor.g, FishUI.Theme.textColor.b, 0.05)
|
||||
|
||||
function updateDrop(x) {
|
||||
pointerX = x
|
||||
dropIndex = Math.max(0, Math.min(tabs.length - 1,
|
||||
Math.floor((x - list.x + list.contentX) / list.tabWidth)))
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: list.x
|
||||
y: list.y
|
||||
width: list.width
|
||||
height: list.height
|
||||
radius: height / 2
|
||||
color: FishUI.Theme.alternateBackgroundColor
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 40
|
||||
anchors.topMargin: 4
|
||||
anchors.bottomMargin: 4
|
||||
orientation: ListView.Horizontal
|
||||
layoutDirection: Qt.LeftToRight
|
||||
clip: true
|
||||
model: control.tabs
|
||||
currentIndex: control.currentIndex
|
||||
readonly property real tabWidth: Math.max(140, width / Math.max(1, count))
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
onCurrentIndexChanged: {
|
||||
if (control.dragIndex < 0)
|
||||
positionViewAtIndex(currentIndex, ListView.Contain)
|
||||
}
|
||||
onCountChanged: Qt.callLater(function() { list.positionViewAtIndex(list.currentIndex, ListView.Contain) })
|
||||
|
||||
delegate: Item {
|
||||
id: tab
|
||||
objectName: "folderTab" + index
|
||||
width: list.tabWidth
|
||||
height: list.height
|
||||
property bool selected: index === control.currentIndex
|
||||
opacity: control.dragIndex === index ? 0.25 : 1
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
radius: height / 2
|
||||
color: tab.selected ? (FishUI.Theme.darkMode ? Qt.lighter(FishUI.Theme.secondBackgroundColor, 2)
|
||||
: FishUI.Theme.secondBackgroundColor)
|
||||
: mouse.containsMouse ? control.hoverColor : "transparent"
|
||||
layer.enabled: tab.selected
|
||||
layer.effect: DropShadow {
|
||||
transparentBorder: true
|
||||
radius: 3
|
||||
samples: 6
|
||||
horizontalOffset: 0
|
||||
verticalOffset: 1
|
||||
color: Qt.rgba(0, 0, 0, FishUI.Theme.darkMode ? 0.18 : 0.12)
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
property real pressX: 0
|
||||
property bool didDrag: false
|
||||
onPressed: function(event) {
|
||||
didDrag = false
|
||||
pressX = mapToItem(control, event.x, event.y).x
|
||||
if (event.button === Qt.LeftButton)
|
||||
control.selected(index)
|
||||
}
|
||||
onPositionChanged: function(event) {
|
||||
if (!(pressedButtons & Qt.LeftButton))
|
||||
return
|
||||
var x = mapToItem(control, event.x, event.y).x
|
||||
if (!didDrag && Math.abs(x - pressX) > 8) {
|
||||
didDrag = true
|
||||
control.dragIndex = index
|
||||
control.dragTitle = modelData.tabTitle
|
||||
}
|
||||
if (didDrag)
|
||||
control.updateDrop(x)
|
||||
}
|
||||
onReleased: {
|
||||
if (didDrag) {
|
||||
var from = control.dragIndex
|
||||
var to = control.dropIndex
|
||||
control.dragIndex = -1
|
||||
control.dropIndex = -1
|
||||
control.moveRequested(from, to)
|
||||
}
|
||||
}
|
||||
onCanceled: {
|
||||
control.dragIndex = -1
|
||||
control.dropIndex = -1
|
||||
}
|
||||
onClicked: function(event) {
|
||||
if (didDrag)
|
||||
return
|
||||
if (event.button === Qt.MiddleButton)
|
||||
control.closeRequested(index)
|
||||
else if (event.button === Qt.RightButton)
|
||||
menu.popup()
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 28
|
||||
anchors.rightMargin: 28
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: modelData.tabTitle
|
||||
font.pixelSize: 12
|
||||
font.weight: tab.selected ? Font.Medium : Font.Normal
|
||||
elide: Text.ElideMiddle
|
||||
color: FishUI.Theme.textColor
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
id: closeButton
|
||||
objectName: "tabCloseButton"
|
||||
width: 20
|
||||
height: 20
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: tab.selected || mouse.containsMouse || hovered ? 1 : 0
|
||||
Accessible.name: qsTr("Close Tab")
|
||||
onClicked: control.closeRequested(index)
|
||||
contentItem: Item {
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: 10
|
||||
height: 1.2
|
||||
rotation: 45
|
||||
color: FishUI.Theme.textColor
|
||||
}
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: 10
|
||||
height: 1.2
|
||||
rotation: -45
|
||||
color: FishUI.Theme.textColor
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: height / 2
|
||||
color: closeButton.down ? control.separatorColor
|
||||
: closeButton.hovered ? control.hoverColor : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
FishUI.DesktopMenu {
|
||||
id: menu
|
||||
FishUI.MenuItem {
|
||||
text: qsTr("Open In New Tab")
|
||||
onTriggered: control.duplicateRequested(modelData.currentUrl)
|
||||
}
|
||||
FishUI.MenuItem {
|
||||
text: qsTr("Close Tab")
|
||||
onTriggered: control.closeRequested(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: control.dragIndex >= 0
|
||||
x: Math.max(list.x, Math.min(list.x + list.width - width, control.pointerX - width / 2))
|
||||
y: 4
|
||||
width: Math.min(list.tabWidth - 2, list.width)
|
||||
height: list.height - 2
|
||||
radius: height / 2
|
||||
color: FishUI.Theme.darkMode ? Qt.lighter(FishUI.Theme.secondBackgroundColor, 2)
|
||||
: FishUI.Theme.secondBackgroundColor
|
||||
opacity: 0.95
|
||||
Label {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: control.dragTitle
|
||||
elide: Text.ElideMiddle
|
||||
color: FishUI.Theme.textColor
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: control.dragIndex >= 0 && control.dropIndex !== control.dragIndex
|
||||
x: Math.max(list.x, Math.min(list.x + list.width - width,
|
||||
list.x + (control.dropIndex + (control.dropIndex > control.dragIndex ? 1 : 0)) * list.tabWidth - list.contentX))
|
||||
y: 6
|
||||
width: 2
|
||||
height: control.height - 12
|
||||
radius: 1
|
||||
color: FishUI.Theme.highlightColor
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 30
|
||||
repeat: true
|
||||
running: control.dragIndex >= 0
|
||||
onTriggered: {
|
||||
var delta = control.pointerX < list.x + 24 ? -8
|
||||
: control.pointerX > list.x + list.width - 24 ? 8 : 0
|
||||
list.contentX = Math.max(0, Math.min(Math.max(0, list.contentWidth - list.width), list.contentX + delta))
|
||||
control.updateDrop(control.pointerX)
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
id: addButton
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
text: "+"
|
||||
font.pixelSize: 20
|
||||
Accessible.name: qsTr("New Tab")
|
||||
onClicked: control.newRequested()
|
||||
background: Rectangle {
|
||||
radius: height / 2
|
||||
color: addButton.hovered ? control.hoverColor : "transparent"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
#include "application.h"
|
||||
#include "qmltypes.h"
|
||||
|
||||
#include <QQuickWindow>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTimer>
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QTemporaryDir config;
|
||||
qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
|
||||
CutefishFM::registerQmlTypes();
|
||||
CutefishFM::initResources();
|
||||
Application app(argc, argv);
|
||||
QWindow helper;
|
||||
helper.show();
|
||||
bool closedLastWindow = false;
|
||||
|
||||
auto browserWindows = [] {
|
||||
QList<QWindow *> windows;
|
||||
for (auto *window : QGuiApplication::topLevelWindows()) {
|
||||
if (window->isVisible() && window->property("tabs").isValid())
|
||||
windows.append(window);
|
||||
}
|
||||
return windows;
|
||||
};
|
||||
|
||||
QTimer::singleShot(0, &app, [&] {
|
||||
app.openFiles({config.path(), config.path()});
|
||||
const auto windows = browserWindows();
|
||||
if (windows.size() != 2) {
|
||||
app.exit(2);
|
||||
return;
|
||||
}
|
||||
windows.first()->close();
|
||||
QTimer::singleShot(100, &app, [&] {
|
||||
const auto remaining = browserWindows();
|
||||
if (remaining.size() != 1) {
|
||||
app.exit(3);
|
||||
return;
|
||||
}
|
||||
closedLastWindow = true;
|
||||
remaining.first()->close();
|
||||
});
|
||||
});
|
||||
QTimer::singleShot(5000, &app, [&] { app.exit(4); });
|
||||
const int result = app.exec();
|
||||
return closedLastWindow ? result : 5;
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
#include "qmltypes.h"
|
||||
#include "model/foldermodel.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QQuickItem>
|
||||
#include <QQuickWindow>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QQmlExpression>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <functional>
|
||||
|
||||
class TabsTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void navigationAndLifetime()
|
||||
{
|
||||
QTemporaryDir folders;
|
||||
QVERIFY(folders.isValid());
|
||||
QDir dir(folders.path());
|
||||
QVERIFY(dir.mkdir("first"));
|
||||
QVERIFY(dir.mkdir("second"));
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
CutefishFM::registerImageProviders(&engine);
|
||||
engine.rootContext()->setContextProperty("arg", folders.path());
|
||||
engine.load(QUrl("qrc:/qml/main.qml"));
|
||||
QVERIFY(!engine.rootObjects().isEmpty());
|
||||
QObject *window = engine.rootObjects().first();
|
||||
QObject *menuBar = window->findChild<QObject *>("applicationMenuBar");
|
||||
QVERIFY(menuBar);
|
||||
QSignalSpy menuWindowChanges(menuBar, SIGNAL(windowChanged()));
|
||||
QVERIFY(menuWindowChanges.isValid());
|
||||
auto evaluate = [&](const QString &code) {
|
||||
QQmlExpression expression(engine.rootContext(), window, code);
|
||||
const QVariant result = expression.evaluate();
|
||||
if (expression.hasError())
|
||||
qWarning() << expression.error();
|
||||
return result;
|
||||
};
|
||||
auto activePage = [&]() {
|
||||
return window->property("_folderPage").value<QObject *>();
|
||||
};
|
||||
auto modelFor = [](QObject *page) {
|
||||
return qobject_cast<FolderModel *>(page->property("model").value<QObject *>());
|
||||
};
|
||||
|
||||
QCOMPARE(evaluate("tabs.length").toInt(), 1);
|
||||
auto *bar = window->findChild<QQuickItem *>("folderTabBar");
|
||||
QVERIFY(bar);
|
||||
QVERIFY(!bar->isVisible());
|
||||
QCOMPARE(bar->height(), 0);
|
||||
QObject *firstPage = activePage();
|
||||
QVERIFY(firstPage);
|
||||
FolderModel *firstModel = modelFor(firstPage);
|
||||
QVERIFY(firstModel);
|
||||
QTRY_COMPARE(firstModel->rowCount(), 2);
|
||||
const QString initialUrl = firstModel->url();
|
||||
firstModel->setUrl(QUrl::fromLocalFile(dir.filePath("first")).toString());
|
||||
QTRY_VERIFY(firstModel->canGoBack());
|
||||
const QString firstUrl = firstModel->url();
|
||||
|
||||
QVERIFY(QMetaObject::invokeMethod(window, "openTab", Q_ARG(QVariant, dir.filePath("second"))));
|
||||
QCOMPARE(evaluate("tabs.length").toInt(), 2);
|
||||
QCOMPARE(window->property("currentTab").toInt(), 1);
|
||||
QVERIFY(bar->isVisible());
|
||||
QCOMPARE(bar->height(), 38);
|
||||
QObject *secondPage = activePage();
|
||||
QVERIFY(secondPage != firstPage);
|
||||
QVERIFY(!firstPage->property("visible").toBool());
|
||||
QVERIFY(secondPage->property("visible").toBool());
|
||||
QCOMPARE(secondPage->property("tabTitle").toString(), QStringLiteral("second"));
|
||||
FolderModel *secondModel = modelFor(secondPage);
|
||||
QVERIFY(secondModel);
|
||||
const QString secondUrl = secondModel->url();
|
||||
|
||||
window->setProperty("currentTab", 0);
|
||||
QCOMPARE(activePage(), firstPage);
|
||||
QCOMPARE(firstModel->url(), firstUrl);
|
||||
QVERIFY(firstModel->canGoBack());
|
||||
firstModel->goBack();
|
||||
QTRY_COMPARE(firstModel->url(), initialUrl);
|
||||
QCOMPARE(secondModel->url(), secondUrl);
|
||||
|
||||
QTRY_COMPARE(firstModel->rowCount(), 2);
|
||||
firstModel->selectAll();
|
||||
firstModel->prepareContextMenu();
|
||||
QVERIFY(firstModel->action("openInNewTab")->isVisible());
|
||||
firstModel->openInNewTab();
|
||||
QCOMPARE(evaluate("tabs.length").toInt(), 4);
|
||||
QCOMPARE(firstModel->selectionCount(), 2);
|
||||
|
||||
QPointer<QObject> closing = activePage();
|
||||
QVERIFY(QMetaObject::invokeMethod(window, "closeTab", Q_ARG(QVariant, 3)));
|
||||
QTRY_VERIFY(closing.isNull());
|
||||
QCOMPARE(evaluate("tabs.length").toInt(), 3);
|
||||
evaluate("closeTab(2); closeTab(1)");
|
||||
QCOMPARE(activePage(), firstPage);
|
||||
QCOMPARE(evaluate("tabs.length").toInt(), 1);
|
||||
QVERIFY(!bar->isVisible());
|
||||
QCOMPARE(bar->height(), 0);
|
||||
|
||||
auto *quickWindow = qobject_cast<QQuickWindow *>(window);
|
||||
QVERIFY(quickWindow);
|
||||
QTest::keyClick(quickWindow, Qt::Key_T, Qt::ControlModifier);
|
||||
QTRY_COMPARE(evaluate("tabs.length").toInt(), 2);
|
||||
QTest::keyClick(quickWindow, Qt::Key_Tab, Qt::ControlModifier);
|
||||
QTRY_COMPARE(window->property("currentTab").toInt(), 0);
|
||||
QTest::keyClick(quickWindow, Qt::Key_W, Qt::ControlModifier);
|
||||
QTRY_COMPARE(evaluate("tabs.length").toInt(), 1);
|
||||
|
||||
QVERIFY(QMetaObject::invokeMethod(window, "openTab", Q_ARG(QVariant, dir.filePath("second"))));
|
||||
QObject *draggedPage = activePage();
|
||||
std::function<QQuickItem *(QQuickItem *, const QString &)> findItem;
|
||||
findItem = [&](QQuickItem *parent, const QString &name) -> QQuickItem * {
|
||||
if (parent->objectName() == name)
|
||||
return parent;
|
||||
for (auto *child : parent->childItems()) {
|
||||
if (auto *found = findItem(child, name))
|
||||
return found;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
QTRY_VERIFY(findItem(bar, "folderTab1"));
|
||||
auto *draggedTab = findItem(bar, "folderTab1");
|
||||
auto *targetTab = findItem(bar, "folderTab0");
|
||||
QVERIFY(targetTab);
|
||||
auto *closeButton = draggedTab->findChild<QQuickItem *>("tabCloseButton");
|
||||
QVERIFY(closeButton);
|
||||
QVERIFY(closeButton->x() > draggedTab->width() / 2);
|
||||
const QPoint from = draggedTab->mapToScene(QPointF(draggedTab->width() / 2, draggedTab->height() / 2)).toPoint();
|
||||
const QPoint to = targetTab->mapToScene(QPointF(targetTab->width() / 2, targetTab->height() / 2)).toPoint();
|
||||
QTest::mousePress(quickWindow, Qt::LeftButton, Qt::NoModifier, from);
|
||||
QTest::mouseMove(quickWindow, to, 30);
|
||||
QTRY_COMPARE(bar->property("dragIndex").toInt(), 1);
|
||||
QTest::mouseRelease(quickWindow, Qt::LeftButton, Qt::NoModifier, to);
|
||||
QTRY_COMPARE(window->property("currentTab").toInt(), 0);
|
||||
QCOMPARE(activePage(), draggedPage);
|
||||
QCOMPARE(evaluate("tabs.length").toInt(), 2);
|
||||
QCOMPARE(menuWindowChanges.count(), 0);
|
||||
QCOMPARE(window->findChildren<QObject *>("applicationMenuBar").count(), 1);
|
||||
if (qEnvironmentVariableIsSet("FILEMANAGER_TEST_SCREENSHOT")) {
|
||||
// The software renderer cannot render FishUI's rounded-window shader mask.
|
||||
window->setProperty("windowRadius", 0);
|
||||
QTest::qWait(150);
|
||||
QVERIFY(quickWindow->grabWindow().save(qEnvironmentVariable("FILEMANAGER_TEST_SCREENSHOT")));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QTemporaryDir config;
|
||||
qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
|
||||
QApplication app(argc, argv);
|
||||
app.setOrganizationName("cutefish-tests");
|
||||
app.setApplicationName("tabs");
|
||||
CutefishFM::registerQmlTypes();
|
||||
CutefishFM::initResources();
|
||||
TabsTest test;
|
||||
return QTest::qExec(&test, argc, argv);
|
||||
}
|
||||
|
||||
#include "tst_tabs.moc"
|
||||
Loading…
Reference in New Issue