274 lines
12 KiB
QML
274 lines
12 KiB
QML
// 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
|
|
|
|
// Which edge this dock lives on, and everything that follows from it. The
|
|
// bottom case is unchanged in every particular: same anchors, same
|
|
// geometry, same slide -- so a machine that never touches the setting sees
|
|
// exactly the dock it had.
|
|
readonly property string position: Settings.dockPosition
|
|
readonly property bool vertical: root.position === "left" || root.position === "right"
|
|
|
|
// Only on the screens asked for. An empty list means all of them, which is
|
|
// what a single-monitor machine wants and what an unplugged display should
|
|
// not be able to change.
|
|
readonly property bool onThisScreen: {
|
|
const wanted = Settings.dockScreens;
|
|
if (!wanted || wanted.length === 0)
|
|
return true;
|
|
return wanted.indexOf(String(root.screen?.name ?? "")) >= 0;
|
|
}
|
|
|
|
visible: root.onThisScreen
|
|
|
|
// A dock spans the edge it lives on, which means anchoring BOTH ends of
|
|
// that edge: bottom+left+right across the screen, or top+bottom plus one
|
|
// side down it. Anchoring only one end leaves the surface free to collapse
|
|
// to its implicit size on that axis -- a side dock came out one pixel tall.
|
|
anchors {
|
|
top: root.vertical
|
|
bottom: true
|
|
left: root.position !== "right"
|
|
right: root.position !== "left"
|
|
}
|
|
|
|
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 edgeMargin: Theme.barGap
|
|
readonly property int tooltipSpace: 34
|
|
|
|
// Only the axis the dock is thin on gets an implicit size; the other is
|
|
// spanned by the anchors above. Setting both would fight them.
|
|
implicitHeight: root.vertical ? 0 : tooltipSpace + body.implicitHeight + edgeMargin
|
|
implicitWidth: root.vertical ? tooltipSpace + body.implicitWidth + edgeMargin : 0
|
|
|
|
// ── Intellihide ─────────────────────────────────────────────────────────
|
|
// This instance's own monitor, the same lookup Workspaces.qml uses to
|
|
// scope a per-screen bar to its own screen. Falls back to null when this
|
|
// Dock is created standalone (no `screen` set) rather than via Variants.
|
|
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
|
|
|
|
// 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.
|
|
//
|
|
// Deliberately this instance's own monitor's active workspace, not the
|
|
// globally-focused one -- with one Dock per screen, keying off the global
|
|
// focus would make focusing an empty workspace on monitor A hide the dock
|
|
// on monitor B even though B's own workspace is still busy.
|
|
readonly property bool workspaceOccupied: {
|
|
const ws = root.monitor ? root.monitor.activeWorkspace : 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. It is one
|
|
// shared flag but there is one Dock per monitor, so only the instance on
|
|
// the currently-focused monitor is allowed to write it -- otherwise
|
|
// whichever instance last changed reveal state would stomp the others,
|
|
// and a reader would see an arbitrary monitor's value. This scopes the
|
|
// flag to mean "is the dock revealed on the monitor the user is on",
|
|
// which is what a capture overlay or the bar actually care about.
|
|
// (A true per-monitor flag would need ShellState.dockRevealed itself to
|
|
// become keyed by screen, which is out of scope here -- see the report.)
|
|
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
|
|
|
|
onRevealedChanged: root._syncShellState()
|
|
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
|
|
|
function _syncShellState(): void {
|
|
if (root.isFocusedMonitorInstance)
|
|
ShellState.dockRevealed = root.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;
|
|
root._syncShellState();
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
|
|
// Revealed: the dock plus everything between it and the edge, so
|
|
// crossing the gap does not count as leaving. Hidden: a sliver along
|
|
// the edge the dock lives on, which is the only thing that can bring
|
|
// it back -- every other click passes through to the window beneath.
|
|
Item {
|
|
id: maskItem
|
|
|
|
x: {
|
|
if (!root.revealed)
|
|
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
|
|
// A horizontal dock is centred on its edge, so the region has
|
|
// to start where the body starts -- starting at 0 puts it over
|
|
// the left of the screen while the pointer that summoned the
|
|
// dock is in the middle, and the hover drops on the very frame
|
|
// the dock arrives. A vertical dock reaches from the body out
|
|
// to its own edge, which is x 0 on the left and the body on
|
|
// the right.
|
|
if (!root.vertical)
|
|
return body.x;
|
|
return root.position === "right" ? body.x : 0;
|
|
}
|
|
y: {
|
|
if (!root.revealed)
|
|
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
|
return body.y;
|
|
}
|
|
width: {
|
|
if (!root.revealed)
|
|
return root.vertical ? root.revealStripHeight : surface.width;
|
|
return root.vertical
|
|
? (root.position === "right" ? surface.width - body.x : body.x + body.width)
|
|
: body.width;
|
|
}
|
|
height: {
|
|
if (!root.revealed)
|
|
return root.vertical ? surface.height : root.revealStripHeight;
|
|
return root.vertical ? body.height : surface.height - body.y;
|
|
}
|
|
}
|
|
|
|
DockBody {
|
|
id: body
|
|
|
|
onContextMenuRequested: (anchorItem, entry) => {
|
|
dockContextMenu.anchorItem = anchorItem;
|
|
dockContextMenu.entry = entry;
|
|
dockContextMenu.visible = true;
|
|
}
|
|
|
|
vertical: root.vertical
|
|
leftSide: root.position === "left"
|
|
|
|
// Both axes are computed rather than anchored. Anchoring the centre
|
|
// on one axis and binding a position on the other looks tidier and
|
|
// is a conflict: an anchored centre owns that coordinate, so the
|
|
// binding beside it is fighting for the same value.
|
|
//
|
|
// Centred on the long axis; on the short one it sits a tooltip's
|
|
// width in from the edge when revealed, and off-screen when not.
|
|
x: {
|
|
if (!root.vertical)
|
|
return (surface.width - width) / 2;
|
|
if (root.position === "left")
|
|
return root.revealed ? root.tooltipSpace : -width;
|
|
return root.revealed ? surface.width - width - root.tooltipSpace : surface.width;
|
|
}
|
|
y: {
|
|
if (root.vertical)
|
|
return (surface.height - height) / 2;
|
|
return root.revealed ? root.tooltipSpace : surface.height;
|
|
}
|
|
opacity: root.revealed ? 1 : 0
|
|
|
|
Behavior on x {
|
|
NumberAnimation {
|
|
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
|
|
easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
DockContextMenu {
|
|
id: dockContextMenu
|
|
}
|
|
}
|