Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-07-22 21:11:19 -04:00
commit 132d8134f2
4 changed files with 77 additions and 231 deletions

View file

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

View file

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

View file

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

View file

@ -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 : ""
}
}
}