298 lines
10 KiB
QML
298 lines
10 KiB
QML
// One dock icon: the app, its running-window dots, hover scale and click
|
|
// behavior. Reproduces Dash-to-Dock's item: 48px icon, up to four dots
|
|
// underneath, grow-on-hover, click to focus / cycle / launch.
|
|
|
|
import Quickshell
|
|
import Quickshell.Hyprland
|
|
import Quickshell.Widgets
|
|
import QtQuick
|
|
import qs.config
|
|
|
|
Item {
|
|
id: root
|
|
|
|
// { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string,
|
|
// pinned: bool }. Built by DockBody so this file stays presentational.
|
|
required property var app
|
|
|
|
readonly property DesktopEntry entry: app && app.entry ? app.entry : null
|
|
readonly property var windows: app && app.windows ? app.windows : []
|
|
readonly property string label: entry ? entry.name : (app && app.appId ? app.appId : "")
|
|
readonly property bool running: windows.length > 0
|
|
readonly property bool hovered: mouse.containsMouse
|
|
|
|
// Set by DockBody. Which way the dock runs decides which axis a drag reads.
|
|
property bool vertical: false
|
|
|
|
// Reordering, driven from DockBody: whether this icon may be dragged at
|
|
// all, how far it is currently displaced, and whether it is the one being
|
|
// dragged rather than one being pushed aside.
|
|
property bool draggable: false
|
|
property real dragShift: 0
|
|
property bool dragging: false
|
|
|
|
// Emitted so DockBody can drive the single shared tooltip.
|
|
signal entered
|
|
signal exited
|
|
signal contextMenuRequested
|
|
|
|
// The drag, reported as travel along the dock's own axis from where the
|
|
// press landed. DockBody owns what that means.
|
|
//
|
|
// The start also carries the pitch -- one cell plus the gap after it --
|
|
// because the cell size is this file's business: an item is taller than it
|
|
// is wide (the running dots sit under the icon), so a slot down a side dock
|
|
// is further than a slot across a bottom one. Measuring it here rather than
|
|
// recomputing it in DockBody keeps one copy of that arithmetic.
|
|
signal dragStarted(real pitch)
|
|
signal dragMoved(real travel)
|
|
signal dragEnded
|
|
signal dragCancelled
|
|
|
|
// The icon may grow past the cell on hover; the cell itself stays a fixed
|
|
// size so the row doesn't reflow.
|
|
implicitWidth: Theme.dockIconSize
|
|
implicitHeight: Theme.dockIconSize + dots.height + 4
|
|
|
|
// Above the icons it is passing.
|
|
z: root.dragging ? 2 : 0
|
|
|
|
// A transform, not an x/y binding: the Grid owns those, and assigning them
|
|
// in a delegate fights the positioner rather than moving the icon.
|
|
transform: Translate {
|
|
x: root.vertical ? 0 : root.dragShift
|
|
y: root.vertical ? root.dragShift : 0
|
|
}
|
|
|
|
// Desktop entries usually carry a freedesktop icon *name*, but some ship an
|
|
// absolute path. iconPath() only understands names, so branch on it. The
|
|
// `true` argument makes a missing icon return "" instead of a placeholder
|
|
// that renders as a black square in some themes.
|
|
readonly property string iconSource: {
|
|
const name = entry && entry.icon ? entry.icon : (app && app.appId ? app.appId : "");
|
|
if (!name)
|
|
return "";
|
|
if (name.startsWith("/"))
|
|
return "file://" + name;
|
|
return Quickshell.iconPath(name, true);
|
|
}
|
|
|
|
Item {
|
|
id: iconSlot
|
|
width: Theme.dockIconSize
|
|
height: Theme.dockIconSize
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
y: 0
|
|
|
|
// Grow upward out of the dock, the way GNOME's dash does, so the icon
|
|
// never overlaps its own running dots.
|
|
transformOrigin: Item.Bottom
|
|
scale: root.hovered ? 1.15 : 1.0
|
|
|
|
Behavior on scale {
|
|
NumberAnimation {
|
|
duration: Theme.durFast
|
|
easing.type: Easing.OutBack
|
|
easing.overshoot: 1.6
|
|
}
|
|
}
|
|
|
|
IconImage {
|
|
anchors.fill: parent
|
|
visible: root.iconSource !== ""
|
|
source: root.iconSource
|
|
asynchronous: true
|
|
mipmap: true
|
|
}
|
|
|
|
// Last resort for an app with no resolvable icon: an initial, which is
|
|
// still recognizable, unlike the theme's broken-icon placeholder.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
visible: root.iconSource === ""
|
|
radius: Theme.cardRadius
|
|
border.width: 0
|
|
color: Theme.alpha(Theme.fg, Theme.activeAlpha)
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: root.label ? root.label.charAt(0).toUpperCase() : "?"
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Math.round(Theme.dockIconSize * 0.5)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Running indicators. Dash-to-Dock showed one dot per window up to four.
|
|
Row {
|
|
id: dots
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.bottom: parent.bottom
|
|
spacing: 3
|
|
height: 4
|
|
|
|
Repeater {
|
|
model: Math.min(root.windows.length, 4)
|
|
|
|
Rectangle {
|
|
width: 4
|
|
height: 4
|
|
radius: 2
|
|
border.width: 0 // QTBUG-137166: transparent rounded rects punch corners
|
|
color: Theme.accent
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: mouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
|
|
|
// Where the press landed, and whether it has travelled far enough to
|
|
// stop being a click. The threshold is the whole reason a drag can
|
|
// share this MouseArea with the launch click: a hand that means to
|
|
// click never moves eight pixels while the button is down.
|
|
readonly property int dragThreshold: 8
|
|
property real pressX: 0
|
|
property real pressY: 0
|
|
property bool dragActive: false
|
|
property bool dragConsumed: false
|
|
|
|
// A wheel notch is 120 units, but a touchpad sends far smaller ones and
|
|
// a free-spinning wheel sends larger. Accumulating and spending whole
|
|
// notches is what makes both feel the same -- reacting to every event
|
|
// would make a touchpad flick blur through every window an app owns.
|
|
property int wheelTravel: 0
|
|
|
|
onEntered: root.entered()
|
|
onExited: root.exited()
|
|
|
|
onWheel: event => {
|
|
if (!root.running) {
|
|
mouse.wheelTravel = 0;
|
|
return;
|
|
}
|
|
mouse.wheelTravel += event.angleDelta.y;
|
|
while (mouse.wheelTravel >= 120) {
|
|
mouse.wheelTravel -= 120;
|
|
root.focusBy(-1);
|
|
}
|
|
while (mouse.wheelTravel <= -120) {
|
|
mouse.wheelTravel += 120;
|
|
root.focusBy(1);
|
|
}
|
|
}
|
|
|
|
onPressed: mev => {
|
|
// A press arriving while a drag is still marked active means the
|
|
// last one never got its release -- a grab stolen by a surface that
|
|
// opened over the dock, most often. Clearing the flag alone would
|
|
// leave DockBody still holding a dragIndex, and with it the
|
|
// interaction hold that keeps the dock revealed forever; the drag
|
|
// has to be cancelled through the same path a stolen grab uses.
|
|
if (mouse.dragActive) {
|
|
mouse.dragActive = false;
|
|
mouse.dragConsumed = false;
|
|
root.dragCancelled();
|
|
}
|
|
mouse.pressX = mev.x;
|
|
mouse.pressY = mev.y;
|
|
mouse.dragActive = false;
|
|
mouse.dragConsumed = false;
|
|
}
|
|
|
|
onPositionChanged: mev => {
|
|
if (!root.draggable || !mouse.pressedButtons)
|
|
return;
|
|
const travel = root.vertical ? mev.y - mouse.pressY : mev.x - mouse.pressX;
|
|
if (!mouse.dragActive) {
|
|
if (Math.abs(travel) < mouse.dragThreshold)
|
|
return;
|
|
mouse.dragActive = true;
|
|
mouse.dragConsumed = true;
|
|
root.dragStarted((root.vertical ? root.height : root.width) + Theme.dockGap);
|
|
}
|
|
root.dragMoved(travel);
|
|
}
|
|
|
|
onReleased: {
|
|
if (!mouse.dragActive)
|
|
return;
|
|
mouse.dragActive = false;
|
|
root.dragEnded();
|
|
}
|
|
|
|
onCanceled: {
|
|
if (!mouse.dragActive)
|
|
return;
|
|
mouse.dragActive = false;
|
|
mouse.dragConsumed = false;
|
|
root.dragCancelled();
|
|
}
|
|
|
|
onClicked: mev => {
|
|
// A gesture that reordered the dock is not also a launch.
|
|
if (mouse.dragConsumed) {
|
|
mouse.dragConsumed = false;
|
|
return;
|
|
}
|
|
// Middle click always starts a new instance, as in GNOME.
|
|
if (mev.button === Qt.MiddleButton) {
|
|
root.launch();
|
|
return;
|
|
}
|
|
if (mev.button === Qt.RightButton) {
|
|
root.contextMenuRequested();
|
|
return;
|
|
}
|
|
if (root.running)
|
|
root.focusBy(1);
|
|
else
|
|
root.launch();
|
|
}
|
|
}
|
|
|
|
function launch(): void {
|
|
if (root.entry)
|
|
root.entry.execute();
|
|
}
|
|
|
|
// Stepping through an app's windows: clicking a running app takes one step
|
|
// forward, matching GNOME's dash, and the wheel takes one in either
|
|
// direction. With nothing of this app focused, any step lands on its first
|
|
// window rather than counting from a window the user is not looking at.
|
|
function focusBy(delta: int): void {
|
|
const wins = root.windows;
|
|
if (wins.length === 0)
|
|
return;
|
|
|
|
let current = -1;
|
|
for (let i = 0; i < wins.length; i++) {
|
|
if (wins[i].activated) {
|
|
current = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const next = current < 0
|
|
? wins[0]
|
|
: wins[((current + delta) % wins.length + wins.length) % wins.length];
|
|
if (!next)
|
|
return;
|
|
|
|
// Quickshell reports the address without the 0x prefix Hyprland's
|
|
// window selector expects; normalize so either form works.
|
|
if (next.address) {
|
|
const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address;
|
|
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`);
|
|
} else if (next.wayland) {
|
|
// No address yet (the toplevel is still being reported) — the
|
|
// wlr handle can still raise it.
|
|
next.wayland.activate();
|
|
}
|
|
}
|
|
}
|