Files

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