From 59fa83a5e62ce1d6d86d0a4e64c671f958039534 Mon Sep 17 00:00:00 2001 From: reionwong Date: Sat, 5 Sep 2026 07:32:32 -0400 Subject: [PATCH] feat(tabs): reorder tabs live while dragging and drop the tab shadow --- qml/FolderTabBar.qml | 328 ++++++++++++++++++++++--------------------- qml/main.qml | 10 -- 2 files changed, 166 insertions(+), 172 deletions(-) diff --git a/qml/FolderTabBar.qml b/qml/FolderTabBar.qml index 87212a3..18c02e7 100644 --- a/qml/FolderTabBar.qml +++ b/qml/FolderTabBar.qml @@ -1,16 +1,17 @@ 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 + // Model index of the tab being dragged, and the slot it currently occupies. property int dragIndex: -1 property int dropIndex: -1 property real pointerX: 0 - property string dragTitle: "" + property real dragPos: 0 + property real dragOffset: 0 signal selected(int index) signal closeRequested(int index) signal newRequested() @@ -18,15 +19,25 @@ Rectangle { signal moveRequested(int from, int to) color: FishUI.Theme.secondBackgroundColor - readonly property color separatorColor: Qt.rgba(FishUI.Theme.textColor.r, + readonly property color pressedColor: 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) { + // Where a tab sits while a drag is in progress: the dragged one is taken + // out of the order and put back at dropIndex, so the rest slide aside. + function slotFor(index) { + if (dragIndex < 0 || index === dragIndex) + return index + var without = index - (index > dragIndex ? 1 : 0) + return without + (without >= dropIndex ? 1 : 0) + } + + function updateDrag(x) { pointerX = x - dropIndex = Math.max(0, Math.min(tabs.length - 1, - Math.floor((x - list.x + list.contentX) / list.tabWidth))) + var limit = Math.max(0, list.contentWidth - list.tabWidth) + dragPos = Math.max(0, Math.min(limit, x - list.x + list.contentX - dragOffset)) + dropIndex = Math.max(0, Math.min(tabs.length - 1, Math.round(dragPos / list.tabWidth))) } Rectangle { @@ -38,194 +49,184 @@ Rectangle { color: FishUI.Theme.alternateBackgroundColor } - ListView { + Flickable { 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)) + interactive: control.dragIndex < 0 + flickableDirection: Flickable.HorizontalFlick boundsBehavior: Flickable.StopAtBounds - onCurrentIndexChanged: { - if (control.dragIndex < 0) - positionViewAtIndex(currentIndex, ListView.Contain) + contentWidth: tabWidth * count + contentHeight: height + + readonly property int count: control.tabs.length + readonly property real tabWidth: Math.max(140, width / Math.max(1, count)) + + function ensureVisible(index) { + if (index < 0 || index >= count) + return + var left = index * tabWidth + var limit = Math.max(0, contentWidth - width) + contentX = Math.max(0, Math.min(limit, + left < contentX ? left + : left + tabWidth > contentX + width ? left + tabWidth - width + : contentX)) } - 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 + Repeater { + model: control.tabs + + delegate: Item { + id: tab + objectName: "folderTab" + index + width: list.tabWidth + height: list.height + x: control.dragIndex === index ? control.dragPos + : control.slotFor(index) * list.tabWidth + z: control.dragIndex === index ? 2 : tab.selected ? 1 : 0 + property bool selected: index === control.currentIndex - 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) + // Only the shuffling aside during a drag animates; opening or + // closing a tab must place the rest at once. + Behavior on x { + enabled: control.dragIndex >= 0 && control.dragIndex !== index + NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } - } - 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) + 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" } - 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 + + 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.dragOffset = event.x + control.selected(index) + } } - if (didDrag) - control.updateDrop(x) - } - onReleased: { - if (didDrag) { + 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.dropIndex = index + } + if (didDrag) + control.updateDrag(x) + } + onReleased: { + if (!didDrag) + return var from = control.dragIndex var to = control.dropIndex + // Clear first: the tabs are already in their final + // places, so the reorder must not animate them again. 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() + } } - 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 - } + 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 + ToolButton { + id: closeButton + objectName: "tabCloseButton" + width: 20 + height: 20 + anchors.right: parent.right + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + visible: control.dragIndex < 0 + 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 + } } - Rectangle { - anchors.centerIn: parent - width: 10 - height: 1.2 - rotation: -45 - color: FishUI.Theme.textColor + background: Rectangle { + radius: height / 2 + color: closeButton.down ? control.pressedColor + : closeButton.hovered ? control.hoverColor : "transparent" } } - 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) + 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 + onCurrentIndexChanged: { + if (control.dragIndex < 0) + list.ensureVisible(control.currentIndex) } + onTabsChanged: Qt.callLater(function() { list.ensureVisible(control.currentIndex) }) Timer { interval: 30 @@ -234,8 +235,11 @@ Rectangle { 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) + if (delta === 0) + return + list.contentX = Math.max(0, Math.min(Math.max(0, list.contentWidth - list.width), + list.contentX + delta)) + control.updateDrag(control.pointerX) } } diff --git a/qml/main.qml b/qml/main.qml index 223d270..6003ca3 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -345,16 +345,6 @@ FishUI.Window { anchors.bottom: parent.bottom z: 3 - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - height: 1 - color: Qt.rgba(FishUI.Theme.textColor.r, - FishUI.Theme.textColor.g, - FishUI.Theme.textColor.b, FishUI.Theme.darkMode ? 0.16 : 0.12) - } - RowLayout { anchors.fill: parent anchors.leftMargin: FishUI.Units.smallSpacing * 1.5