Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/modules/ii/dock/DockAppButton.qml
Fimeg 8f42fc953d publish: the public projection begins here
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
2026-09-04 15:55:48 -04:00

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