Build the Panama Hyprland desktop
This commit is contained in:
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user