Build the Panama Hyprland desktop

This commit is contained in:
Gabriel Brown
2026-08-17 10:32:55 -04:00
parent 67033f2a31
commit 5248883e4b
190 changed files with 18554 additions and 34 deletions
+152
View File
@@ -0,0 +1,152 @@
// The dock — a replacement for Dash-to-Dock configured as: bottom edge, 48px
// icons, intellihide against all windows, dot running-indicators, show-apps
// button at the leading edge.
//
// Intellihide means the dock reserves no space at all (exclusiveZone 0) and
// floats over windows, appearing when the pointer reaches the bottom edge or
// when nothing is in the way.
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.services
PanelWindow {
id: root
// Set by Variants when instantiated per-screen. Plain rather than
// `required` so the dock can also be created standalone while testing;
// Variants injects into the existing property either way.
property var modelData: null
screen: root.modelData
anchors {
bottom: true
left: true
right: true
}
color: "transparent"
// A dock that reserved space would not be intellihiding.
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
WlrLayershell.namespace: "qs-dock"
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// ── Geometry ────────────────────────────────────────────────────────────
// Height = room for the tooltip above the bar + the bar + the gap under it.
readonly property int revealStripHeight: 3
readonly property int bottomMargin: Theme.barGap
readonly property int tooltipSpace: 34
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
// ── Intellihide ─────────────────────────────────────────────────────────
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
// be computed. "Is anything on this workspace at all" is the robust proxy,
// and it is what Dash-to-Dock's all-windows intellihide felt like in
// practice: an empty workspace keeps the dock out.
readonly property bool workspaceOccupied: {
const ws = Hyprland.focusedWorkspace;
return !!ws && ws.toplevels.values.length > 0;
}
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered
property bool revealed: true
onWantRevealedChanged: {
if (wantRevealed) {
hideTimer.stop();
revealTimer.restart();
} else {
revealTimer.stop();
hideTimer.restart();
}
}
Timer {
id: revealTimer
interval: Settings.dockRevealDelayMs
onTriggered: root.revealed = true
}
Timer {
id: hideTimer
interval: Settings.dockHideDelayMs
onTriggered: root.revealed = false
}
// Other modules (the bar, the capture overlay) read this.
onRevealedChanged: ShellState.dockRevealed = revealed
// wantRevealed's first evaluation emits no change signal when it lands on
// false (the default), so the initial state has to be taken explicitly —
// otherwise a shell started on a busy workspace would leave the dock up.
Component.onCompleted: {
revealed = wantRevealed;
ShellState.dockRevealed = revealed;
}
// ── Input region ────────────────────────────────────────────────────────
// Revealed: the bar plus everything below it, so crossing the gap under the
// dock does not count as leaving. Hidden: a sliver along the screen edge,
// which is the only thing that can still trigger the dock — every other
// click passes straight through to the window underneath.
mask: Region {
item: maskItem
}
Item {
id: surface
anchors.fill: parent
// Reports the pointer anywhere inside the input region. A HoverHandler
// rather than a MouseArea because it keeps reporting while the icons'
// own MouseAreas are hovered.
HoverHandler {
id: pointer
}
Item {
id: maskItem
x: root.revealed ? body.x : 0
y: root.revealed ? body.y : surface.height - root.revealStripHeight
width: root.revealed ? body.width : surface.width
height: root.revealed ? surface.height - body.y : root.revealStripHeight
}
DockBody {
id: body
anchors.horizontalCenter: parent.horizontalCenter
// Slides off the bottom edge when hidden.
y: root.revealed ? root.tooltipSpace : surface.height
opacity: root.revealed ? 1 : 0
// Asymmetric on purpose. Revealing is a response to something the
// user just did, so it has to feel immediate — any delay there
// reads as lag. Hiding is not a response to anything, so it can
// take its time and stay calm in peripheral vision.
Behavior on y {
NumberAnimation {
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic
}
}
Behavior on opacity {
NumberAnimation {
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
easing.type: Easing.OutCubic
}
}
}
}
}
@@ -0,0 +1,203 @@
// 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: row.implicitWidth + Theme.dockPadding * 2
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
// The item the tooltip is currently describing, or null.
property Item hoveredItem: null
Row {
id: row
anchors.centerIn: parent
spacing: Theme.dockGap
ShowAppsButton {
id: showApps
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = showApps
onExited: if (root.hoveredItem === showApps)
root.hoveredItem = null
}
// Separator between the launcher and the apps, as in GNOME's dash.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 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
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null
}
}
}
// ── 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, clamped inside the dock. Reads the item's
// x directly (rather than mapToItem) so the binding re-evaluates when
// the row reflows.
x: {
if (!root.hoveredItem)
return 0;
const centre = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
}
y: -height - 8
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
}
}
}
@@ -0,0 +1,168 @@
// One dock icon: the app, its running-window dots, hover scale and click
// behaviour. 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 }
// 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
// Emitted so DockBody can drive the single shared tooltip.
signal entered
signal exited
// 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
// 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 recognisable, 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
onEntered: root.entered()
onExited: root.exited()
onClicked: mev => {
// Middle click always starts a new instance, as in GNOME.
if (mev.button === Qt.MiddleButton) {
root.launch();
return;
}
if (root.running)
root.focusNext();
else
root.launch();
}
}
function launch(): void {
if (root.entry)
root.entry.execute();
}
// Clicking a running app cycles through its windows, matching GNOME's dash.
function focusNext(): void {
const wins = root.windows;
if (wins.length === 0)
return;
let next = wins[0];
for (let i = 0; i < wins.length; i++) {
if (wins[i].activated) {
next = wins[(i + 1) % wins.length];
break;
}
}
if (!next)
return;
// Quickshell reports the address without the 0x prefix Hyprland's
// window selector expects; normalise 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();
}
}
}
@@ -0,0 +1,79 @@
// The leading "show applications" button. GNOME's dash had
// show-apps-at-top: true, which on a bottom dock means the leading edge.
//
// The glyph is drawn rather than pulled from the icon theme: GNOME's app-grid
// icon is literally a 3x3 dot grid, and drawing it removes any dependency on
// which icon theme happens to be installed.
import Quickshell
import QtQuick
import qs.config
Item {
id: root
signal entered
signal exited
readonly property bool hovered: mouse.containsMouse
readonly property string label: qsTr("Show Applications")
implicitWidth: Theme.dockIconSize
implicitHeight: Theme.dockIconSize
Rectangle {
id: bg
anchors.centerIn: parent
width: Theme.dockIconSize
height: Theme.dockIconSize
radius: width / 2
border.width: 0 // QTBUG-137166
color: root.hovered ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
transformOrigin: Item.Center
scale: root.hovered ? 1.15 : 1.0
Behavior on scale {
NumberAnimation {
duration: Theme.durFast
easing.type: Easing.OutBack
easing.overshoot: 1.6
}
}
Grid {
anchors.centerIn: parent
columns: 3
spacing: Math.round(Theme.dockIconSize * 0.11)
Repeater {
model: 9
Rectangle {
readonly property int dot: Math.round(Theme.dockIconSize * 0.135)
width: dot
height: dot
radius: dot / 2
border.width: 0
color: Theme.fg
}
}
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
onEntered: root.entered()
onExited: root.exited()
// vicinae is the launcher this shell uses in place of GNOME's app grid.
onClicked: Quickshell.execDetached(["vicinae", "toggle"])
}
}