dock: add drag-to-reorder for pinned apps alongside existing combine-into-stack
Dragging a pinned app now shows an insertion gap indicator when hovering over other pinned apps. Quick horizontal slide + release = reorder the pinned apps array. Dwell (500ms) on a target still = combine into stack (existing behavior). Both gestures share the same drag start and ghost. TaskbarApps: reorderPinned() splice-moves within Config.options.dock.pinnedApps. DockApps: shared dragSourceIndex/dragInsertIndex state, delegate passes parent.index as modelIndex to DockAppButton. DockAppButton: insertion indicator (2px primary-color line), DropArea onEntered computes gap position based on drag direction, release handler commits reorder when no dwell fired. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9c27504009
commit
132d8134f2
4 changed files with 77 additions and 231 deletions
|
|
@ -13,6 +13,7 @@ DockButton {
|
|||
id: root
|
||||
property var appToplevel
|
||||
property var appListRoot
|
||||
property int modelIndex: -1 // set by the delegate Loader (parent.index)
|
||||
property int lastFocused: -1
|
||||
property real iconSize: 35
|
||||
property real countDotWidth: 10
|
||||
|
|
@ -32,6 +33,25 @@ DockButton {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Insertion gap indicator (drag-to-reorder) ----------------------
|
||||
// A thin vertical line at the LEFT edge of this delegate when it is
|
||||
// the current reorder target. Sits above the button content so it's
|
||||
// always visible during a drag.
|
||||
Rectangle {
|
||||
visible: appListRoot.dragInsertIndex >= 0
|
||||
&& root.modelIndex === appListRoot.dragInsertIndex
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -1 // straddle the ListView spacing
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 2
|
||||
height: parent.height * 0.65
|
||||
radius: 1
|
||||
color: Appearance.colors.colPrimary
|
||||
z: 10
|
||||
opacity: visible ? 1 : 0
|
||||
Behavior on opacity { NumberAnimation { duration: 80 } }
|
||||
}
|
||||
|
||||
// --- Drag-to-combine (Tier 1) --------------------------------------
|
||||
// Pattern copied from this codebase's OverviewWidget.qml (drag a window
|
||||
// onto a workspace): a MouseArea with drag.target moves a ghost Item;
|
||||
|
|
@ -96,6 +116,9 @@ DockButton {
|
|||
// Structural dock edits (manifest pin/stack mutations) are
|
||||
// refused while a drag is in flight — see DockManifest.
|
||||
GlobalStates.dockDragInProgress = true
|
||||
// Store source index for reorder computation.
|
||||
appListRoot.dragSourceIndex = root.modelIndex
|
||||
appListRoot.dragInsertIndex = -1
|
||||
// Hold-then-move means drag, not menu: a menu that opened on
|
||||
// the hold gets dismissed the moment real movement starts.
|
||||
root.menuOpen = false
|
||||
|
|
@ -112,10 +135,21 @@ DockButton {
|
|||
dragging = false
|
||||
GlobalStates.dockDragInProgress = false
|
||||
if (target && target.toLowerCase() !== root.appToplevel.appId.toLowerCase()) {
|
||||
// Dwell fired → combine into stack (existing path).
|
||||
TaskbarApps.combineIntoStack(target, root.appToplevel.appId, targetStack)
|
||||
} else if (appListRoot.dragInsertIndex >= 0
|
||||
&& appListRoot.dragInsertIndex !== appListRoot.dragSourceIndex) {
|
||||
// No dwell, valid insertion gap → reorder pinned app.
|
||||
const stacksCount = TaskbarApps.stacksList().length
|
||||
const targetPinnedIdx = appListRoot.dragInsertIndex - stacksCount
|
||||
if (targetPinnedIdx >= 0) {
|
||||
TaskbarApps.reorderPinned(root.appToplevel.appId, targetPinnedIdx)
|
||||
}
|
||||
}
|
||||
appListRoot.dragTargetAppId = ""
|
||||
appListRoot.dragTargetStackId = ""
|
||||
appListRoot.dragInsertIndex = -1
|
||||
appListRoot.dragSourceIndex = -1
|
||||
return
|
||||
}
|
||||
if (root.menuOpen) return // long-press already handled it
|
||||
|
|
@ -129,6 +163,8 @@ DockButton {
|
|||
dragging = false
|
||||
dragGhost.Drag.active = false
|
||||
GlobalStates.dockDragInProgress = false
|
||||
appListRoot.dragInsertIndex = -1
|
||||
appListRoot.dragSourceIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +173,22 @@ DockButton {
|
|||
onEntered: (drag) => {
|
||||
if (drag.source === root) return
|
||||
dwellTimer.restart()
|
||||
// --- Reorder: compute insertion gap position ---------------
|
||||
// Only pinned apps (not stacks, not running apps) participate
|
||||
// in reordering. The gap appears BEFORE the target if dragging
|
||||
// left (source > target), AFTER if dragging right (source <
|
||||
// target) — matching the physical intuition of sliding an icon
|
||||
// into a new slot.
|
||||
const srcIdx = appListRoot.dragSourceIndex
|
||||
const tgtIdx = root.modelIndex
|
||||
const stacksCount = TaskbarApps.stacksList().length
|
||||
const pinnedCount = Config.options?.dock.pinnedApps?.length ?? 0
|
||||
if (srcIdx >= stacksCount && srcIdx < stacksCount + pinnedCount
|
||||
&& tgtIdx >= stacksCount && tgtIdx < stacksCount + pinnedCount
|
||||
&& srcIdx !== tgtIdx
|
||||
&& root.appToplevel.pinned && !root.appToplevel.isStack) {
|
||||
appListRoot.dragInsertIndex = srcIdx < tgtIdx ? tgtIdx + 1 : tgtIdx
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
dwellTimer.stop()
|
||||
|
|
@ -153,6 +205,8 @@ DockButton {
|
|||
root.formingStack = true
|
||||
appListRoot.dragTargetAppId = root.appToplevel.appId
|
||||
appListRoot.dragTargetStackId = "" // plain app -> new stack
|
||||
// Dwell = combine intent, not reorder — clear the gap.
|
||||
appListRoot.dragInsertIndex = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ Item {
|
|||
// would outgrow the screen. Past it the ListView scrolls horizontally.
|
||||
property real maxWidth: -1
|
||||
|
||||
// Drag-to-reorder state, shared across all DockAppButton delegates.
|
||||
// dragSourceIndex: model index of the button currently being dragged.
|
||||
// dragInsertIndex: model index BEFORE which the insertion gap shows.
|
||||
// -1 means no valid reorder target. Set by DropArea.onEntered when
|
||||
// the drag hovers a pinned app; cleared on release / cancel / dwell.
|
||||
property int dragSourceIndex: -1
|
||||
property int dragInsertIndex: -1
|
||||
|
||||
Layout.fillHeight: true
|
||||
// No top-only margin: it shoved the whole icon list DOWN inside the bar
|
||||
// with nothing balancing it — the actual cause of icons drooping below
|
||||
|
|
@ -67,6 +75,7 @@ Item {
|
|||
}
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
required property int index
|
||||
// Fan-out stack entries render as DockStack; everything else
|
||||
// (pinned apps, running windows, separators) as DockAppButton.
|
||||
sourceComponent: modelData.isStack ? stackComp : appComp
|
||||
|
|
@ -76,6 +85,7 @@ Item {
|
|||
DockAppButton {
|
||||
appToplevel: modelData
|
||||
appListRoot: root
|
||||
modelIndex: parent.index
|
||||
topInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
|
||||
bottomInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,19 @@ Singleton {
|
|||
root.writeStacks(parsed);
|
||||
}
|
||||
|
||||
// Reorder a pinned app within the pinnedApps array. targetPinnedIndex
|
||||
// is the desired final index (0-based) in Config.options.dock.pinnedApps.
|
||||
// The app is removed from its current position and spliced in at the
|
||||
// target; other pins shift to fill / make room.
|
||||
function reorderPinned(appId, targetPinnedIndex) {
|
||||
const pinned = Config.options.dock.pinnedApps.slice();
|
||||
const srcIdx = pinned.findIndex(id => id.toLowerCase() === appId.toLowerCase());
|
||||
if (srcIdx < 0 || srcIdx === targetPinnedIndex) return;
|
||||
pinned.splice(srcIdx, 1);
|
||||
pinned.splice(targetPinnedIndex, 0, appId);
|
||||
Config.options.dock.pinnedApps = pinned;
|
||||
}
|
||||
|
||||
// Pull a member out of its stack back to a standalone pinned app.
|
||||
function unstackMember(stackId, appId) {
|
||||
root.removeFromStack(stackId, appId);
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
//@ pragma UseQApplication
|
||||
//@ pragma Env QS_NO_RELOAD_POPUP=1
|
||||
//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic
|
||||
//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000
|
||||
|
||||
// Souveraine mobile settings — purpose-built for portrait/touch.
|
||||
// Modeled on TouchLockSurface.qml: a full-screen surface with phone-native
|
||||
// stack navigation (list -> push page -> back), not the desktop settings.qml's
|
||||
// ApplicationWindow with titlebar + side rail. Reuses the existing
|
||||
// ConfigSwitch/ConfigSpinBox pages (PhoneConfig.qml etc.) as the pushed
|
||||
// content; only the chrome is mobile.
|
||||
//
|
||||
// Sized for 540x1080 logical (1080x2160 @ scale 2).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Window
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions as CF
|
||||
|
||||
ApplicationWindow {
|
||||
id: root
|
||||
property int currentPage: -1 // -1 = list view; >=0 = drilled into that page
|
||||
readonly property real rowHeight: 68 // finger-sized tap target
|
||||
readonly property real edgeMargin: 16
|
||||
|
||||
// The page set. Same {name, icon, component} shape as the desktop
|
||||
// settings.qml registry so a page is interchangeable between the two.
|
||||
// One page per domain we own; About reuses ii's stock page verbatim.
|
||||
property var pages: [
|
||||
{
|
||||
name: Translation.tr("Device"),
|
||||
icon: "smartphone",
|
||||
component: "modules/settings/DeviceConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Lock screen"),
|
||||
icon: "lock",
|
||||
component: "modules/settings/LockConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Wallpaper"),
|
||||
icon: "wallpaper",
|
||||
component: "modules/settings/WallpaperConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Dock"),
|
||||
icon: "dock_to_bottom",
|
||||
component: "modules/settings/DockConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Navigation"),
|
||||
icon: "gesture",
|
||||
component: "modules/settings/NavigationConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Idle & sleep"),
|
||||
icon: "bedtime",
|
||||
component: "modules/settings/IdleConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Keyboard"),
|
||||
icon: "keyboard",
|
||||
component: "modules/settings/KeyboardConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Speech"),
|
||||
icon: "mic",
|
||||
component: "modules/settings/SpeechConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("About"),
|
||||
icon: "info",
|
||||
component: "modules/settings/About.qml"
|
||||
}
|
||||
// Next: Display (brightness/idle), Cellular (mmcli service),
|
||||
// Bluetooth (after org.bluez activation is fixed), app health.
|
||||
]
|
||||
|
||||
visible: true
|
||||
onClosing: Qt.quit()
|
||||
title: "Settings"
|
||||
// Full-screen on the phone: no titlebar, no window-frame padding, edge to edge.
|
||||
visibility: Window.FullScreen
|
||||
color: Appearance.m3colors.m3background
|
||||
|
||||
Component.onCompleted: {
|
||||
MaterialThemeLoader.reapplyTheme()
|
||||
Config.readWriteDelay = 0
|
||||
}
|
||||
|
||||
// --- List view (idle state) ------------------------------------------
|
||||
ColumnLayout {
|
||||
id: listView
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
visible: root.currentPage < 0
|
||||
|
||||
// Header
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: root.edgeMargin
|
||||
Layout.rightMargin: root.edgeMargin
|
||||
Layout.topMargin: root.edgeMargin + 16
|
||||
Layout.bottomMargin: 8
|
||||
implicitHeight: titleText.implicitHeight
|
||||
StyledText {
|
||||
id: titleText
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Appearance.colors.colOnLayer0
|
||||
text: Translation.tr("Settings")
|
||||
font {
|
||||
family: Appearance.font.family.title
|
||||
pixelSize: Appearance.font.pixelSize.title
|
||||
variableAxes: Appearance.font.variableAxes.title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-width finger-sized rows
|
||||
StyledListView {
|
||||
id: pageList
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
model: root.pages
|
||||
delegate: RippleButton {
|
||||
required property var index
|
||||
required property var modelData
|
||||
width: pageList.width
|
||||
height: root.rowHeight
|
||||
buttonRadius: 0
|
||||
onClicked: root.currentPage = index
|
||||
contentItem: RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: root.edgeMargin
|
||||
anchors.rightMargin: root.edgeMargin
|
||||
spacing: 16
|
||||
MaterialSymbol {
|
||||
text: modelData.icon
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
color: Appearance.colors.colOnLayer0
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: modelData.name
|
||||
color: Appearance.colors.colOnLayer0
|
||||
font.pixelSize: Appearance.font.pixelSize.larger
|
||||
}
|
||||
MaterialSymbol {
|
||||
text: "chevron_right"
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
color: Appearance.colors.colSubtext
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page view (drilled-in state) ------------------------------------
|
||||
Item {
|
||||
id: pageView
|
||||
anchors.fill: parent
|
||||
visible: root.currentPage >= 0
|
||||
|
||||
// Back header
|
||||
Item {
|
||||
id: pageHeader
|
||||
anchors {
|
||||
top: parent.top
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
}
|
||||
height: 56
|
||||
RippleButton {
|
||||
id: backButton
|
||||
anchors {
|
||||
left: parent.left
|
||||
leftMargin: 4
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
buttonRadius: Appearance.rounding.full
|
||||
implicitWidth: 48
|
||||
implicitHeight: 48
|
||||
onClicked: root.currentPage = -1
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: "arrow_back"
|
||||
iconSize: 24
|
||||
}
|
||||
}
|
||||
StyledText {
|
||||
anchors {
|
||||
left: backButton.right
|
||||
leftMargin: 8
|
||||
right: parent.right
|
||||
rightMargin: root.edgeMargin
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
color: Appearance.colors.colOnLayer0
|
||||
text: root.currentPage >= 0 ? root.pages[root.currentPage].name : ""
|
||||
elide: Text.ElideRight
|
||||
font {
|
||||
family: Appearance.font.family.title
|
||||
pixelSize: Appearance.font.pixelSize.larger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pushed page. ContentPage is a StyledFlickable that clamps to
|
||||
// baseWidth 600 when forceWidth; we let it scroll and give it the
|
||||
// full window width so it reads edge-to-edge.
|
||||
Loader {
|
||||
id: pageLoader
|
||||
anchors {
|
||||
top: pageHeader.bottom
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
bottom: parent.bottom
|
||||
}
|
||||
active: root.currentPage >= 0 && Config.ready
|
||||
source: root.currentPage >= 0 ? root.pages[root.currentPage].component : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue