Files
Panama/config/dot/quickshell/modules/dock/DockBody.qml
T

236 lines
8.9 KiB
QML

// The visible dock bar: the rounded translucent pill, its items, and the
// tooltip that floats above it.
//
// Kept separate from Dock.qml so it can be dropped straight into a
// FloatingWindow for testing — PanelWindow cannot map without layer shell.
import Quickshell
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.widgets
Rectangle {
id: root
// ── Model ───────────────────────────────────────────────────────────────
// One pass over the live toplevel list produces the whole dock: pinned
// apps first in Settings order, then anything else that is running.
// Entries are plain JS objects: { entry, windows, appId }.
readonly property var items: {
// DesktopEntries is scanned asynchronously at startup, and byId() is a
// plain method call that creates no binding dependency. Reading the
// model here is what makes the dock fill in once the scan lands.
if (DesktopEntries.applications.values.length === 0)
return [];
const groups = {}; // appId -> [HyprlandToplevel]
const order = []; // appIds in first-seen order
const toplevels = Hyprland.toplevels.values;
for (let i = 0; i < toplevels.length; i++) {
const t = toplevels[i];
// `wayland` is null until Hyprland reports the address, and it is
// the only live source of an app id — there is no `class` property.
const appId = t.wayland ? t.wayland.appId : "";
if (!appId)
continue;
if (!groups[appId]) {
groups[appId] = [];
order.push(appId);
}
groups[appId].push(t);
}
const out = [];
const claimed = {};
const pinned = Settings.dockPinned;
for (let i = 0; i < pinned.length; i++) {
const entry = DesktopEntries.byId(pinned[i]);
// A pin that no longer resolves is dropped rather than drawn as a
// broken icon.
if (!entry)
continue;
let windows = [];
for (let j = 0; j < order.length; j++) {
if (!claimed[order[j]] && root.matches(order[j], entry, pinned[i])) {
claimed[order[j]] = true;
windows = windows.concat(groups[order[j]]);
}
}
out.push({
entry: entry,
windows: windows,
appId: pinned[i]
});
}
for (let i = 0; i < order.length; i++) {
const appId = order[i];
if (claimed[appId])
continue;
out.push({
entry: DesktopEntries.heuristicLookup(appId),
windows: groups[appId],
appId: appId
});
}
return out;
}
// Wayland app ids and desktop entry ids agree often but not always
// (`org.gnome.Nautilus` vs `nautilus`, `Steam` vs `steam`), so compare the
// usual candidates case-insensitively and fall back to the last
// reverse-DNS segment.
function matches(appId: string, entry: var, pinId: string): bool {
const a = appId.toLowerCase();
if (a === pinId.toLowerCase())
return true;
if (entry.id && a === entry.id.toLowerCase())
return true;
if (entry.startupClass && a === entry.startupClass.toLowerCase())
return true;
const tail = a.split(".").pop();
return tail === pinId.toLowerCase().split(".").pop();
}
// ── Appearance ──────────────────────────────────────────────────────────
radius: Theme.dockRadius
color: Theme.alpha(Theme.bgPanel, Theme.dockAlpha)
border.width: 0 // QTBUG-137166: rounded translucent rects lose their corners
// The prism edge — see widgets/PrismEdge.qml.
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
// The item the tooltip is currently describing, or null.
property Item hoveredItem: null
signal contextMenuRequested(Item anchorItem, var entry)
// Set by the Dock. A side dock runs the same strip down the screen instead
// of across it.
property bool vertical: false
// Which way a tooltip points on a side dock: away from the screen edge, so
// it never opens off-screen.
property bool leftSide: true
// Explicit rather than left to Grid's wrapping. This is always one line, so
// saying how many cells it holds is both simpler to read and immune to
// Grid's default column count quietly wrapping a long dock.
readonly property int cellCount: 2 + (root.items ? root.items.length : 0)
// A Grid rather than a Row so one declaration serves both orientations.
// Row and Column would each need their own children, and the cross-axis
// anchors that centre items in a Row (verticalCenter) are the wrong axis in
// a Column -- Grid centres through its own alignment properties instead,
// which is the same result without the anchors positioners disallow.
Grid {
id: strip
anchors.centerIn: parent
spacing: Theme.dockGap
rows: root.vertical ? root.cellCount : 1
columns: root.vertical ? 1 : root.cellCount
horizontalItemAlignment: Grid.AlignHCenter
verticalItemAlignment: Grid.AlignVCenter
ShowAppsButton {
id: showApps
onEntered: root.hoveredItem = showApps
onExited: if (root.hoveredItem === showApps)
root.hoveredItem = null
}
// Separator between the launcher and the apps, as in GNOME's dash. It
// turns with the dock: a hairline across a column, down a row.
Rectangle {
width: root.vertical ? Theme.dockIconSize * 0.7 : 1
height: root.vertical ? 1 : Theme.dockIconSize * 0.7
border.width: 0
color: Theme.alpha(Theme.fg, 0.14)
}
Repeater {
model: root.items
DockItem {
id: dockItem
required property var modelData
app: modelData
onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
}
}
}
// ── Tooltip ─────────────────────────────────────────────────────────────
// Styled like widgets/Popover.qml. Deliberately outside the dock's bounds
// (negative y): it renders fine there, and the dock's input mask does not
// cover it, so it can never swallow a click.
Rectangle {
id: tooltip
readonly property string text: root.hoveredItem ? root.hoveredItem.label : ""
visible: opacity > 0
opacity: root.hoveredItem && tipLabel.text ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Theme.durFast
}
}
// Centred on the hovered item along the dock's own axis, and placed just
// outside the edge it lives on. Reads the item's position directly
// (rather than mapToItem) so the binding re-evaluates when the strip
// reflows.
x: {
if (!root.hoveredItem)
return 0;
if (root.vertical)
return root.leftSide ? root.width + 8 : -width - 8;
const centre = strip.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
}
y: {
if (!root.hoveredItem)
return -height - 8;
if (!root.vertical)
return -height - 8;
const centre = strip.y + root.hoveredItem.y + root.hoveredItem.height / 2;
return Math.max(4, Math.min(root.height - height - 4, centre - height / 2));
}
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
height: tipLabel.implicitHeight + 8
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
Text {
id: tipLabel
anchors.centerIn: parent
text: tooltip.text
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}