add slide-in applet dock with collapsible cards, edge trigger, and bar module toggle

This commit is contained in:
Damocles 2026-04-25 21:32:04 +02:00
parent 6fd36c812f
commit c22eb51dcd
14 changed files with 689 additions and 14 deletions

409
shell/dock/AppletDock.qml Normal file
View file

@ -0,0 +1,409 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "." as D
import "../services" as S
import "../applets" as C
import "../modules" as M
PanelWindow {
id: root
required property var screen
visible: D.DockState.open
color: "transparent"
WlrLayershell.layer: D.DockState.mode === "pinned" ? WlrLayer.Top : WlrLayer.Overlay
WlrLayershell.exclusiveZone: D.DockState.mode === "pinned" ? _dockWidth : 0
WlrLayershell.namespace: "nova-dock"
anchors.top: true
anchors.right: true
anchors.bottom: true
readonly property int _dockWidth: S.Modules.dock.width ?? 300
readonly property var _applets: S.Modules.dock.applets ?? {}
readonly property color _accent: S.Theme.base0C
implicitWidth: _dockWidth
// Slide animation
property real _slideX: D.DockState.open ? 0 : _dockWidth
Behavior on _slideX {
enabled: !S.Theme.reducedMotion
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
// Overlay mode: close when cursor leaves
HoverHandler {
id: _dockHover
onHoveredChanged: {
if (!hovered && D.DockState.mode === "overlay")
_overlayCloseTimer.restart();
else
_overlayCloseTimer.stop();
}
}
Timer {
id: _overlayCloseTimer
interval: 200
onTriggered: if (D.DockState.mode === "overlay")
D.DockState.close()
}
// Background
Rectangle {
id: _bg
anchors.fill: parent
color: S.Theme.base00
opacity: Math.max(S.Theme.barOpacity, 0.85)
transform: Translate {
x: root._slideX
}
}
// Accent border on the left edge
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 1
color: root._accent
opacity: _bg.opacity
transform: Translate {
x: root._slideX
}
}
// Content
Flickable {
id: _flickable
anchors.fill: parent
contentHeight: _column.height
clip: true
boundsBehavior: Flickable.StopAtBounds
transform: Translate {
x: root._slideX
}
Column {
id: _column
width: root._dockWidth
spacing: 8
topPadding: 8
bottomPadding: 8
leftPadding: 8
rightPadding: 8
// Clock
D.DockCard {
visible: root._applets.clock ?? true
icon: "\uF017"
title: "Clock"
accentColor: root._accent
width: parent.width - 16
C.ClockApplet {
width: parent.width
accentColor: root._accent
currentDate: _clock.date
}
}
// CPU
D.DockCard {
id: _cpuCard
visible: root._applets.cpu ?? true
icon: "\uF2DB"
title: "CPU"
accentColor: root._accent
width: parent.width - 16
C.CpuApplet {
width: parent.width
cores: S.SystemStats.cpuCores
coreMaxFreq: S.SystemStats.cpuCoreMaxFreq
coreTypes: S.SystemStats.cpuCoreTypes
processes: _cpuProcs.processes
accentColor: root._accent
active: _cpuCard.expanded
}
}
// GPU
D.DockCard {
visible: (root._applets.gpu ?? true) && S.SystemStats.gpuAvailable
icon: "\uEB4C"
title: "GPU"
accentColor: root._accent
width: parent.width - 16
C.GpuApplet {
width: parent.width
active: parent.expanded
accentColor: root._accent
}
}
// Memory
D.DockCard {
id: _memCard
visible: root._applets.memory ?? true
icon: "\uEFC5"
title: "Memory"
accentColor: root._accent
width: parent.width - 16
C.MemoryApplet {
width: parent.width
percent: S.SystemStats.memPercent
usedGb: S.SystemStats.memUsedGb
totalGb: S.SystemStats.memTotalGb
availGb: S.SystemStats.memAvailGb
cachedGb: S.SystemStats.memCachedGb
buffersGb: S.SystemStats.memBuffersGb
processes: _memProcs.processes
accentColor: root._accent
active: _memCard.expanded
}
}
// Temperature
D.DockCard {
visible: root._applets.temperature ?? true
icon: "\uF2C9"
title: "Temperature"
accentColor: root._accent
width: parent.width - 16
C.TemperatureApplet {
width: parent.width
temp: S.SystemStats.tempCelsius
warm: S.Modules.temperature.warm || 80
hot: S.Modules.temperature.hot || 90
history: S.SystemStats.tempHistory
devices: S.SystemStats.tempDevices
accentColor: root._accent
deviceFilter: S.Modules.temperature.device || ""
active: parent.parent.expanded
}
}
// Disk
D.DockCard {
visible: root._applets.disk ?? true
icon: "\uF0C9"
title: "Disk"
accentColor: root._accent
width: parent.width - 16
C.DiskApplet {
width: parent.width
mounts: S.SystemStats.diskMounts
accentColor: root._accent
}
}
// Battery
D.DockCard {
visible: (root._applets.battery ?? true) && S.BatteryService.available
icon: "\uDB80\uDC84"
title: "Battery"
accentColor: root._accent
width: parent.width - 16
C.BatteryApplet {
width: parent.width
active: parent.expanded
accentColor: root._accent
}
}
// Network
D.DockCard {
visible: root._applets.network ?? true
icon: "\uF1EB"
title: "Network"
accentColor: root._accent
width: parent.width - 16
C.NetworkApplet {
width: parent.width
accentColor: root._accent
}
}
// Bluetooth
D.DockCard {
visible: (root._applets.bluetooth ?? true) && S.BluetoothService.state !== "unavailable"
icon: "\uF294"
title: "Bluetooth"
accentColor: root._accent
width: parent.width - 16
C.BluetoothApplet {
width: parent.width
accentColor: root._accent
}
}
// Volume
D.DockCard {
visible: root._applets.volume ?? true
icon: "\uF028"
title: "Sound"
accentColor: root._accent
width: parent.width - 16
C.VolumeApplet {
width: parent.width
sink: Pipewire.defaultAudioSink
sinkList: root._sinkList
streamList: root._streamList
accentColor: root._accent
}
}
// Backlight
D.DockCard {
visible: (root._applets.backlight ?? true) && S.BacklightService.available
icon: "\uF185"
title: "Brightness"
accentColor: root._accent
width: parent.width - 16
C.BacklightApplet {
width: parent.width
percent: S.BacklightService.percent
accentColor: root._accent
onSetPercent: pct => S.BacklightService.setPercent(pct)
}
}
// Weather
D.DockCard {
visible: (root._applets.weather ?? true) && S.WeatherService.available
icon: S.WeatherService.icon
title: "Weather"
accentColor: root._accent
width: parent.width - 16
C.WeatherApplet {
width: parent.width
accentColor: root._accent
}
}
// Now Playing
D.DockCard {
visible: (root._applets.mpris ?? true) && S.MprisService.player !== null
icon: "\uF04B"
title: "Now Playing"
accentColor: root._accent
width: parent.width - 16
C.MprisApplet {
width: parent.width
player: S.MprisService.player
players: S.MprisService.players
playing: S.MprisService.playing
accentColor: root._accent
playerIdx: S.MprisService.playerIdx
onPlayerSwitched: idx => S.MprisService.switchPlayer(idx)
}
}
// Notifications
D.DockCard {
visible: root._applets.notifications ?? true
icon: "\uDB80\uDC9C"
title: "Notifications"
accentColor: root._accent
width: parent.width - 16
C.NotifApplet {
width: parent.width
contentWidth: root._dockWidth - 16
accentColor: root._accent
}
}
// Power
D.DockCard {
visible: root._applets.power ?? true
icon: "\uF011"
title: "Power"
accentColor: root._accent
width: parent.width - 16
C.PowerApplet {
width: parent.width
accentColor: root._accent
onRunCommand: cmd => {
_runner.command = cmd;
_runner.running = true;
}
onDismiss: D.DockState.close()
}
}
}
}
// Shared resources
SystemClock {
id: _clock
precision: SystemClock.Seconds
}
M.ProcessList {
id: _cpuProcs
sortBy: "cpu"
active: _cpuCard.expanded && D.DockState.open
}
M.ProcessList {
id: _memProcs
sortBy: "mem"
active: _memCard.expanded && D.DockState.open
}
PwObjectTracker {
objects: [Pipewire.defaultAudioSink, ...root._streamList]
}
readonly property var _sinkList: {
const sinks = [];
if (Pipewire.nodes) {
for (const node of Pipewire.nodes.values)
if (!node.isStream && node.isSink)
sinks.push(node);
}
return sinks;
}
readonly property var _streamList: {
const streams = [];
if (Pipewire.nodes) {
for (const node of Pipewire.nodes.values)
if (node.isStream && node.audio)
streams.push(node);
}
return streams;
}
Process {
id: _runner
}
}

100
shell/dock/DockCard.qml Normal file
View file

@ -0,0 +1,100 @@
import QtQuick
import "../services" as S
// Collapsible card for the applet dock.
// Set icon, title, and place applet content as children.
Rectangle {
id: root
property string icon: ""
property string title: ""
property bool expanded: false
property color accentColor: S.Theme.base0C
default property alias content: _contentColumn.children
width: parent?.width ?? 300
height: _header.height + (_contentColumn.visible ? _contentColumn.height : 0)
radius: S.Theme.radius + 2
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
clip: true
Behavior on height {
enabled: !S.Theme.reducedMotion
NumberAnimation {
duration: 150
easing.type: Easing.OutCubic
}
}
// Header
Item {
id: _header
width: parent.width
height: 28
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: root.icon
color: root.accentColor
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.iconFontFamily
}
Text {
anchors.left: parent.left
anchors.leftMargin: 30
anchors.verticalCenter: parent.verticalCenter
text: root.title
color: S.Theme.base05
font.pixelSize: S.Theme.fontSize - 1
font.bold: true
font.family: S.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: root.expanded ? "\uF078" : "\uF054"
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.iconFontFamily
Behavior on text {
enabled: false
}
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.expanded = !root.expanded
}
// Divider
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: S.Theme.base03
opacity: root.expanded ? 0.5 : 0
}
}
// Content area
Column {
id: _contentColumn
anchors.top: _header.bottom
anchors.topMargin: 4
width: parent.width
visible: root.expanded
}
}

View file

@ -0,0 +1,32 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "." as D
// Invisible 2px-wide PanelWindow at the right screen edge.
// When cursor enters, opens the dock in overlay mode.
PanelWindow {
id: root
required property var screen
visible: !D.DockState.open
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusiveZone: 0
WlrLayershell.namespace: "nova-dock-trigger"
anchors.top: true
anchors.right: true
anchors.bottom: true
implicitWidth: 2
HoverHandler {
onHoveredChanged: {
if (hovered && !D.DockState.open)
D.DockState.openOverlay();
}
}
}

29
shell/dock/DockState.qml Normal file
View file

@ -0,0 +1,29 @@
pragma Singleton
import QtQuick
QtObject {
// "closed" | "pinned" | "overlay"
property string mode: "closed"
readonly property bool open: mode !== "closed"
function openPinned() {
mode = "pinned";
}
function openOverlay() {
if (mode === "closed")
mode = "overlay";
}
function close() {
mode = "closed";
}
function toggle() {
if (mode === "pinned")
mode = "closed";
else
mode = "pinned";
}
}

7
shell/dock/qmldir Normal file
View file

@ -0,0 +1,7 @@
module dock
# keep-sorted start
AppletDock 1.0 AppletDock.qml
DockCard 1.0 DockCard.qml
DockEdgeTrigger 1.0 DockEdgeTrigger.qml
singleton DockState 1.0 DockState.qml
# keep-sorted end

View file

@ -216,10 +216,13 @@ PanelWindow {
}
}
// Power
// Power + Dock
M.BarGroup {
rightEdge: true
M.BatteryModule {}
M.DockModule {
visible: S.Modules.dock.enable
}
M.PowerModule {
visible: S.Modules.power.enable
}

View file

@ -0,0 +1,15 @@
import QtQuick
import "." as M
import "../services" as S
import "../dock" as D
M.BarModule {
id: root
tooltip: D.DockState.open ? "Close dock" : "Open dock"
onTapped: D.DockState.toggle()
M.BarIcon {
icon: D.DockState.open ? "\uDB80\uDD8B" : "\uDB80\uDD89"
anchors.verticalCenter: parent.verticalCenter
}
}

View file

@ -12,6 +12,7 @@ BluetoothModule 1.0 BluetoothModule.qml
ClockModule 1.0 ClockModule.qml
CpuModule 1.0 CpuModule.qml
DiskModule 1.0 DiskModule.qml
DockModule 1.0 DockModule.qml
GpuModule 1.0 GpuModule.qml
HoverPanel 1.0 HoverPanel.qml
IdleInhibitorModule 1.0 IdleInhibitorModule.qml

View file

@ -104,13 +104,34 @@ QtObject {
weather: true,
threatEffect: true
})
property var dock: ({
enable: true,
width: 300,
applets: {
clock: true,
cpu: true,
gpu: true,
memory: true,
temperature: true,
disk: true,
battery: true,
network: true,
bluetooth: true,
volume: true,
backlight: true,
weather: true,
mpris: true,
notifications: true,
power: true
}
})
property var statsDaemon: ({
interval: -1
})
// All module keys that have an enable flag used to default-enable anything
// not explicitly mentioned in modules.json
readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop", "lock"]
readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop", "lock", "dock"]
// Fallback: if modules.json doesn't exist, enable everything
Component.onCompleted: _apply("{}")

View file

@ -2,6 +2,7 @@
import "modules"
import "services"
import "dock" as Dock
import "lock" as Lock
import Quickshell
@ -57,6 +58,20 @@ ShellRoot {
screen: scope.modelData
}
}
LazyLoader {
active: Modules.dock.enable
Dock.AppletDock {
screen: scope.modelData
}
}
LazyLoader {
active: Modules.dock.enable
Dock.DockEdgeTrigger {
screen: scope.modelData
}
}
}
}
}