This is a projection, not a development branch. The tree above was constructed from the internal source named below under a manifest that decides which paths may leave, then scanned as a whole tree rather than as a series of patches, and only then published. Public history starts here because the history before it was not admissible, and neither was the tree. What used to stand in this repository included a rescue copy of another machine, a directory of phone handoffs, deployment wired to one house, and a submodule pointing at a forge no stranger can reach. None of that was ever the product. It stays in the private forge, which is allowed to hold the whole working organism, and this is what was deliberately sent out instead. Three mechanisms produced this tree, in decreasing order of trust. A top-level path the manifest does not name never arrives at all, which is the one that catches directories nobody has thought of yet. Named internal files inside admitted roots are dropped. A short, reviewed table replaces deployment defaults that a public build must not carry -- an endpoint aimed at one LAN, a VPN profile belonging to one phone, packaging built from one checkout path. Everything after this commit is an ordinary publication with the same three trailers, so a force push stops being routine and starts meaning that something deliberate happened. The trailers bind the projection to its source without pretending the public SHA is the private one: same lineage, different tree, and the record says so. Source-Sha: 8f27b1e76a8fef560a336aba18e6990713ff1047 Policy-Sha: 6b261d2f3e6e1fb19874846ba4bb1dfe15565d25b8618c1c1afba0419c101d27 Tree-Digest: 18ec3563c5e5ef9a414993a9f6734b251ff9ed3cd56eebdd6cac01e45c6e3067
445 lines
18 KiB
QML
445 lines
18 KiB
QML
import qs
|
|
import qs.services
|
|
import qs.modules.common
|
|
import qs.modules.common.widgets
|
|
import qs.modules.common.functions
|
|
import Qt5Compat.GraphicalEffects
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import Quickshell.Widgets
|
|
|
|
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: Config.options?.dock.iconSize ?? 44
|
|
property real countDotWidth: 10
|
|
property real countDotHeight: 4
|
|
property bool appIsActive: appToplevel.toplevels.find(t => (t.activated == true)) !== undefined
|
|
|
|
readonly property bool isSeparator: appToplevel.appId === "SEPARATOR"
|
|
// Suffix-tolerant resolve: a bare heuristicLookup returns null for app_ids
|
|
// that carry an instance suffix (Firefox reports "firefox-default"), and a
|
|
// null entry makes tapping a pinned-but-not-running icon a silent no-op.
|
|
property var desktopEntry: AppSearch.resolveEntry(appToplevel.appId)
|
|
enabled: !isSeparator
|
|
implicitWidth: isSeparator ? 1 : implicitHeight - topInset - bottomInset
|
|
|
|
Connections {
|
|
target: DesktopEntries
|
|
|
|
function onApplicationsChanged() {
|
|
root.desktopEntry = AppSearch.resolveEntry(appToplevel.appId);
|
|
}
|
|
}
|
|
|
|
// --- 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;
|
|
// Drag.active/source/hotSpot are set imperatively on press; the release
|
|
// reads which DropArea the drag ended over (appListRoot.dragTargetAppId,
|
|
// set by the DropArea's onEntered) and commits. NOT Qt's DropArea.onDropped
|
|
// — the anchored-proxy version never moved, so no DropArea ever saw it.
|
|
// The ghost therefore carries NO anchors: Qt refuses to move an anchored
|
|
// drag.target, and an unmoving ghost generates no enter events — the
|
|
// exact bug the line above describes. Position is set on press instead.
|
|
// GNOME's dwell rule (500ms before a hover counts as "combine") gates the
|
|
// highlight so a quick brush-past doesn't read as an intended stack.
|
|
property bool formingStack: false // this icon is the current drop target, dwell fired
|
|
readonly property string dragAppId: appToplevel.appId
|
|
|
|
// The ghost that actually moves under the finger (real Drag source).
|
|
Item {
|
|
id: dragGhost
|
|
width: root.iconSize
|
|
height: root.iconSize
|
|
visible: dragMouse.dragging
|
|
Drag.hotSpot.x: width / 2
|
|
Drag.hotSpot.y: height / 2
|
|
z: 100
|
|
IconImage {
|
|
anchors.fill: parent
|
|
source: Quickshell.iconPath(AppSearch.guessIcon(root.appToplevel.appId), "image-missing")
|
|
opacity: 0.9
|
|
}
|
|
}
|
|
|
|
// Single interaction surface (mirrors OverviewWidget's dragArea owning
|
|
// all gestures): tap -> activate/launch, long-press -> menu, drag ->
|
|
// combine. Sits above the visual button; the button's own onClicked is
|
|
// routed here via root.activate() so nothing double-fires.
|
|
// Hold/drag arbitration follows VLC's DelegateTouchTapHandler rule: a
|
|
// long-press may open the menu, but the moment movement turns into a
|
|
// drag the menu is cancelled and the drag owns the gesture (Android
|
|
// home-screen semantics).
|
|
MouseArea {
|
|
id: dragMouse
|
|
anchors.fill: parent
|
|
enabled: !root.isSeparator
|
|
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
|
hoverEnabled: true
|
|
property bool dragging: false
|
|
drag.target: dragGhost
|
|
drag.threshold: 12
|
|
pressAndHoldInterval: 450
|
|
onPressed: (mouse) => {
|
|
// Start the (not yet visible) ghost centered under the finger.
|
|
// drag.target then moves it 1:1 with the pointer, so its center
|
|
// hotspot tracks the finger across sibling DropAreas.
|
|
dragGhost.x = mouse.x - dragGhost.width / 2
|
|
dragGhost.y = mouse.y - dragGhost.height / 2
|
|
}
|
|
drag.onActiveChanged: {
|
|
if (drag.active) {
|
|
dragging = true
|
|
dragGhost.Drag.source = root
|
|
dragGhost.Drag.active = true
|
|
// 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
|
|
}
|
|
}
|
|
onPressAndHold: (mouse) => {
|
|
if (!dragging && mouse.button === Qt.LeftButton) root.menuOpen = true
|
|
}
|
|
onReleased: (mouse) => {
|
|
if (dragging) {
|
|
const target = appListRoot.dragTargetAppId
|
|
const targetStack = appListRoot.dragTargetStackId
|
|
dragGhost.Drag.active = false
|
|
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
|
|
if (mouse.button === Qt.MiddleButton) {
|
|
root.desktopEntry?.execute()
|
|
} else {
|
|
root.activate()
|
|
}
|
|
}
|
|
onCanceled: {
|
|
dragging = false
|
|
dragGhost.Drag.active = false
|
|
GlobalStates.dockDragInProgress = false
|
|
appListRoot.dragInsertIndex = -1
|
|
appListRoot.dragSourceIndex = -1
|
|
}
|
|
}
|
|
|
|
DropArea {
|
|
anchors.fill: parent
|
|
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()
|
|
root.formingStack = false
|
|
if (appListRoot.dragTargetAppId === root.appToplevel.appId) {
|
|
appListRoot.dragTargetAppId = ""
|
|
appListRoot.dragTargetStackId = ""
|
|
}
|
|
}
|
|
Timer {
|
|
id: dwellTimer
|
|
interval: Config.options?.dock.dragDwellMs ?? 500 // GNOME's dwell — intent, not accident
|
|
onTriggered: {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pre-commit preview: a rounded container fades in behind the icon once
|
|
// the dwell fires, so you SEE the stack forming before releasing.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
anchors.margins: -2
|
|
z: -1
|
|
radius: Appearance.rounding.normal
|
|
visible: opacity > 0
|
|
opacity: root.formingStack ? 1 : 0
|
|
color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.55)
|
|
border.width: 1
|
|
border.color: Appearance.colors.colPrimary
|
|
Behavior on opacity { NumberAnimation { duration: 120 } }
|
|
scale: root.formingStack ? 1.08 : 1.0
|
|
Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutBack } }
|
|
}
|
|
|
|
Loader {
|
|
active: isSeparator
|
|
anchors {
|
|
fill: parent
|
|
topMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal
|
|
bottomMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal
|
|
}
|
|
sourceComponent: DockSeparator {}
|
|
}
|
|
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: appToplevel.toplevels.length > 0
|
|
sourceComponent: MouseArea {
|
|
id: mouseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.NoButton
|
|
onEntered: {
|
|
appListRoot.lastHoveredButton = root
|
|
appListRoot.buttonHovered = true
|
|
lastFocused = appToplevel.toplevels.length - 1
|
|
}
|
|
onExited: {
|
|
if (appListRoot.lastHoveredButton === root) {
|
|
appListRoot.buttonHovered = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tap behaviour, called by the unified dragMouse handler above.
|
|
function activate() {
|
|
if (appToplevel.toplevels.length === 0) {
|
|
root.desktopEntry?.execute();
|
|
return;
|
|
}
|
|
// Cycle from the window that is actually focused, not the stored
|
|
// index — that index goes stale as windows open/close and the hover
|
|
// handler also writes it, so tapping could focus the wrong window.
|
|
const cur = appToplevel.toplevels.findIndex(t => t.activated);
|
|
lastFocused = ((cur >= 0 ? cur : Math.max(lastFocused, -1)) + 1) % appToplevel.toplevels.length
|
|
appToplevel.toplevels[lastFocused].activate()
|
|
}
|
|
|
|
middleClickAction: () => {
|
|
root.desktopEntry?.execute();
|
|
}
|
|
|
|
altAction: () => {
|
|
TaskbarApps.togglePin(appToplevel.appId);
|
|
}
|
|
|
|
// Long-press -> per-app context menu (pin, stack membership, settings
|
|
// stub). Opened by dragMouse.onPressAndHold. This is settings-surface
|
|
// entry (b); "App settings…" fires the dockSettings IPC stub.
|
|
property bool menuOpen: false
|
|
|
|
property real menuCenterX: 0
|
|
onMenuOpenChanged: {
|
|
if (menuOpen && QsWindow)
|
|
menuCenterX = QsWindow.mapFromItem(root, root.width / 2, 0).x;
|
|
}
|
|
|
|
PopupWindow {
|
|
id: ctxMenu
|
|
visible: root.menuOpen || ctxContent.opacity > 0
|
|
color: "transparent"
|
|
anchor {
|
|
window: root.QsWindow?.window ?? null
|
|
adjustment: PopupAdjustment.None
|
|
gravity: Edges.Top | Edges.Right
|
|
edges: Edges.Top | Edges.Left
|
|
}
|
|
implicitWidth: root.QsWindow?.window?.width ?? 1
|
|
implicitHeight: ctxColumn.implicitHeight + 18
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
enabled: root.menuOpen
|
|
onClicked: root.menuOpen = false // tap-away closes
|
|
}
|
|
|
|
Rectangle {
|
|
id: ctxContent
|
|
anchors.bottom: parent.bottom
|
|
x: root.menuCenterX - width / 2
|
|
width: 200
|
|
height: ctxColumn.implicitHeight + 12
|
|
radius: Appearance.rounding.normal
|
|
color: Appearance.m3colors.m3surfaceContainer
|
|
border.width: 1
|
|
border.color: Appearance.colors.colLayer0Border
|
|
opacity: root.menuOpen ? 1 : 0
|
|
Behavior on opacity { NumberAnimation { duration: 100 } }
|
|
|
|
ColumnLayout {
|
|
id: ctxColumn
|
|
anchors.fill: parent
|
|
anchors.margins: 6
|
|
spacing: 2
|
|
|
|
component MenuItem: RippleButton {
|
|
Layout.fillWidth: true
|
|
implicitHeight: 34
|
|
property string label: ""
|
|
contentItem: StyledText {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 10
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: parent.label
|
|
color: Appearance.m3colors.m3onSurface
|
|
font.pixelSize: Appearance.font.pixelSize.small
|
|
}
|
|
}
|
|
|
|
// Stack membership is drag-only now: drag onto an icon/stack
|
|
// to combine, drag a member off the arc to unstack. The menu
|
|
// keeps only what drag can't express.
|
|
MenuItem {
|
|
label: TaskbarApps.isPinned(root.appToplevel.appId) ? "Unpin" : "Pin to dock"
|
|
onClicked: { TaskbarApps.togglePin(root.appToplevel.appId); root.menuOpen = false }
|
|
}
|
|
MenuItem {
|
|
label: "App settings…"
|
|
onClicked: {
|
|
// Stub: hand off to the future souveraine-settings app.
|
|
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call",
|
|
"dockSettings", "openApp", root.appToplevel.appId]);
|
|
root.menuOpen = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
contentItem: Loader {
|
|
active: !isSeparator
|
|
// The Loader stretches its loaded item to the Loader's own size, so
|
|
// width/height/centerIn declared ON the loaded item are overridden —
|
|
// that stretched the block to the content area and the top-anchored
|
|
// icon rode the top of the bar. The stretched item is a passthrough;
|
|
// the real fixed-size block centers INSIDE it (same structure as
|
|
// DockStack — keep them identical).
|
|
sourceComponent: Item {
|
|
Item {
|
|
anchors.centerIn: parent
|
|
// Center the icon+dots block. Reserve only HALF the dot strip
|
|
// below the icon: reserving the full strip pushed the block's
|
|
// center below the icon's center, so centerIn left extra gap
|
|
// above the icon (icons read a few px high). Half-reserve splits
|
|
// the difference so the glyph sits visually centered.
|
|
width: root.iconSize
|
|
height: root.iconSize + (root.countDotHeight + 2) / 2
|
|
|
|
Loader {
|
|
id: iconImageLoader
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
top: parent.top
|
|
}
|
|
height: root.iconSize
|
|
active: !root.isSeparator
|
|
sourceComponent: IconImage {
|
|
source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing")
|
|
implicitSize: root.iconSize
|
|
}
|
|
}
|
|
|
|
Loader {
|
|
active: Config.options.dock.monochromeIcons
|
|
anchors.fill: iconImageLoader
|
|
sourceComponent: Item {
|
|
Desaturate {
|
|
id: desaturatedIcon
|
|
visible: false // There's already color overlay
|
|
anchors.fill: parent
|
|
source: iconImageLoader
|
|
desaturation: 0.8
|
|
}
|
|
ColorOverlay {
|
|
anchors.fill: desaturatedIcon
|
|
source: desaturatedIcon
|
|
color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.9)
|
|
}
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
spacing: 3
|
|
anchors {
|
|
top: iconImageLoader.bottom
|
|
topMargin: 2
|
|
horizontalCenter: parent.horizontalCenter
|
|
}
|
|
Repeater {
|
|
model: Math.min(appToplevel.toplevels.length, 3)
|
|
delegate: Rectangle {
|
|
required property int index
|
|
radius: Appearance.rounding.full
|
|
implicitWidth: (appToplevel.toplevels.length <= 3) ?
|
|
root.countDotWidth : root.countDotHeight // Circles when too many
|
|
implicitHeight: root.countDotHeight
|
|
color: appIsActive ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|