Files
dotfiles/quickshell/dashboard/Dashboard.qml
T
nvrl 373584f34e feat(quickshell): quick switches under the dashboard
A row of pill switches under the dashboard's columns: Wi-Fi,
Bluetooth, do not disturb and stay awake, plus night light through
hyprsunset when it is installed. On is the light slab with ink type;
off is a hairline.
2026-10-04 23:34:27 +02:00

774 lines
30 KiB
QML

import QtQuick
import QtQuick.Shapes
import Quickshell
import Quickshell.Widgets
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
import "root:/config"
import "root:/components"
import "root:/services"
// The dashboard: a drawer that slides down out of the underside of the bar.
//
// The idea is caelestia's — the drawer is not a floating card but more of the
// hull, so it is painted in the hull colour and joins the bar through two
// concave fillets, and the porthole's rim runs round it without a break. Read
// against the wallpaper it is a panel lowered from the ceiling of the ship.
//
// Four columns divided by hairlines: who and when, the month, the machine,
// and what is playing. The clock in the bar toggles it, as does
// `qs ipc call shell dashboard`; Escape or a click anywhere else puts it away.
PanelWindow {
id: root
required property var modelData
screen: modelData
readonly property bool open: Actions.dashboardOpen
&& Actions.dashboardScreen === modelData.name
// 0 = tucked under the bar, 1 = fully down.
property real reveal: root.open ? 1 : 0
Behavior on reveal {
NumberAnimation {
duration: Theme.durSlow
easing.type: root.open ? Easing.OutCubic : Easing.InCubic
}
}
visible: root.open || root.reveal > 0
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "takemi-dashboard"
WlrLayershell.keyboardFocus: root.open
? WlrKeyboardFocus.OnDemand
: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
// One pixel up, so the drawer covers the rim the hull draws along the
// underside of the bar instead of sitting below it.
anchors.top: true
margins.top: Theme.barHeight - 1
// The fillets are the same radius as the porthole's corners, so the drawer
// meets the bar the way the opening meets the frame.
readonly property real fillet: Theme.frameRadius
readonly property real radius: Theme.frameRadius
readonly property real pad: Theme.padL
readonly property real bodyWidth: columns.implicitWidth + root.pad * 2
readonly property real bodyHeight: columns.implicitHeight + toggles.implicitHeight
+ Theme.padL + 1 + root.pad * 3
implicitWidth: root.bodyWidth + root.fillet * 2
implicitHeight: root.bodyHeight + 1
color: "transparent"
mask: Region { item: body }
HyprlandFocusGrab {
active: root.open
windows: [root]
onCleared: Actions.closeDashboard()
}
// Who is at the console. Read once; it does not change under a session.
readonly property string user: Quickshell.env("USER") ?? ""
FileView {
id: hostFile
path: "/etc/hostname"
}
readonly property string host: hostFile.text().trim()
SystemClock {
id: clock
precision: SystemClock.Seconds
}
// Everything below slides as one sheet, clipped to the window so it
// appears to come out from behind the bar rather than fading in over it.
Item {
anchors.fill: parent
clip: true
Item {
id: sheet
width: parent.width
height: parent.height
y: -height * (1 - root.reveal)
// ------------------------------------------------------ silhouette
Shape {
id: silhouette
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
readonly property real w: width
readonly property real h: height
readonly property real f: root.fillet
readonly property real r: root.radius
// Down the left fillet, round the bottom, up the right fillet.
readonly property string edge:
`M 0 0 A ${f} ${f} 0 0 1 ${f} ${f}`
+ ` V ${h - r} A ${r} ${r} 0 0 0 ${f + r} ${h}`
+ ` H ${w - f - r} A ${r} ${r} 0 0 0 ${w - f} ${h - r}`
+ ` V ${f} A ${f} ${f} 0 0 1 ${w} 0`
ShapePath {
fillColor: Theme.hull
strokeColor: "transparent"
PathSvg { path: silhouette.edge + " Z" }
}
// The rim, carried on round the drawer from the opening.
ShapePath {
fillColor: "transparent"
strokeColor: Theme.rim
strokeWidth: 1
PathSvg { path: silhouette.edge }
}
}
// ---------------------------------------------------------- body
FocusScope {
id: body
x: root.fillet
width: root.bodyWidth
height: parent.height
focus: root.open
Keys.onEscapePressed: Actions.closeDashboard()
Row {
id: columns
x: root.pad
y: root.pad
spacing: 0
// Who and when: the house mark, the operator, a large clock.
Item {
implicitWidth: 230
implicitHeight: root.columnHeight
Row {
id: who
spacing: Theme.padM
Porthole {
anchors.verticalCenter: parent.verticalCenter
size: 30
lit: true
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
text: root.user.toUpperCase()
color: Theme.text
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsBody
font.weight: Theme.weightDisplay
font.letterSpacing: Theme.tracking
renderType: Text.NativeRendering
}
Text {
text: root.host !== "" ? "@ " + root.host : ""
color: Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 1
renderType: Text.NativeRendering
}
}
}
Row {
id: time
anchors.top: who.bottom
anchors.topMargin: Theme.padL
spacing: Theme.padS
SplitText {
id: hhmm
text: Qt.formatDateTime(clock.date, "HH:mm")
pixelSize: 54
weight: Font.Light
letterSpacing: 2
color: Theme.accent
}
Text {
anchors.baseline: hhmm.bottom
anchors.baselineOffset: -12
text: Qt.formatDateTime(clock.date, "ss")
color: Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsBody
renderType: Text.NativeRendering
}
}
Text {
anchors.top: time.bottom
anchors.topMargin: Theme.padS
text: Qt.formatDateTime(clock.date, "dddd · dd MMMM").toUpperCase()
color: Theme.subtext
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
Column {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
spacing: Theme.padS
Fact { label: "UPTIME"; value: Sys.uptime || "—" }
Fact {
visible: Battery.available
label: "POWER"
value: Math.round(Battery.percent) + "%"
+ (Battery.timeLabel !== "" ? " · " + Battery.timeLabel : "")
}
Fact { label: "NOTICES"; value: Notifs.count > 0 ? Notifs.count + " held" : "none" }
}
}
Rule { height: root.columnHeight }
// The month, Monday first. Today is the NieR cursor: an inverted disc.
Item {
id: month
implicitWidth: 238
implicitHeight: root.columnHeight
// Keyed by the day, not the clock: the clock ticks every
// second, and rebuilding 42 cells that often is waste.
readonly property string dayKey: Qt.formatDateTime(clock.date, "yyyy-MM-dd")
readonly property var cells: {
const now = new Date(month.dayKey + "T12:00:00");
const y = now.getFullYear(), m = now.getMonth();
const lead = (new Date(y, m, 1).getDay() + 6) % 7;
const out = [];
for (let i = 0; i < 42; i++) {
const d = new Date(y, m, 1 - lead + i);
out.push({
day: d.getDate(),
inMonth: d.getMonth() === m,
today: d.toDateString() === now.toDateString()
});
}
return out;
}
SectionRule {
id: monthHead
anchors.left: parent.left
anchors.right: parent.right
text: Qt.formatDateTime(clock.date, "MMMM yyyy")
}
Row {
id: weekdays
anchors.top: monthHead.bottom
anchors.topMargin: Theme.padS
Repeater {
model: ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
Text {
required property var modelData
required property int index
width: month.width / 7
horizontalAlignment: Text.AlignHCenter
text: modelData
color: index >= 5 ? Theme.subtext : Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
renderType: Text.NativeRendering
}
}
}
Grid {
anchors.top: weekdays.bottom
anchors.topMargin: Theme.padS
columns: 7
Repeater {
model: month.cells
Item {
required property var modelData
width: month.width / 7
height: 26
Rectangle {
anchors.centerIn: parent
width: 22
height: 22
radius: 11
visible: parent.modelData.today
color: Theme.treatmentSelectedFill
}
Text {
anchors.centerIn: parent
text: parent.modelData.day
color: parent.modelData.today ? Theme.treatmentSelectedText
: parent.modelData.inMonth ? Theme.text
: Theme.alpha(Theme.muted, 0.5)
font.family: Theme.fontMono
font.pixelSize: Theme.fsSmall
font.weight: parent.modelData.today ? Font.Bold : Font.Normal
renderType: Text.NativeRendering
}
}
}
}
}
Rule { height: root.columnHeight }
// The machine: the same meters the bar's readout opens onto.
Item {
implicitWidth: 238
implicitHeight: root.columnHeight
SectionRule {
id: sysHead
anchors.left: parent.left
anchors.right: parent.right
text: "System"
}
Column {
anchors.top: sysHead.bottom
anchors.topMargin: Theme.padM
anchors.left: parent.left
anchors.right: parent.right
spacing: Theme.padM
MetricRow {
width: parent.width
value: Sys.cpuUsage
label: "CPU"
sub: Math.round(Sys.cpuTemp) + "°C"
}
MetricRow {
width: parent.width
value: Sys.memPercent
label: "MEM"
sub: Sys.gb(Sys.memUsed) + " / " + Sys.gb(Sys.memTotal)
}
MetricRow {
width: parent.width
visible: Sys.gpuAvailable
value: Sys.gpuUsage
label: "GPU"
sub: Math.round(Sys.gpuTemp) + "°C"
}
MetricRow {
width: parent.width
value: Sys.diskPercent
label: "DISK"
sub: Sys.diskFree + " free"
}
}
}
Rule { height: root.columnHeight }
// What is playing. With no player it says so, in the voice NieR's
// interface uses for an empty channel.
Item {
implicitWidth: 280
implicitHeight: root.columnHeight
SectionRule {
id: playHead
anchors.left: parent.left
anchors.right: parent.right
text: Media.hasPlayer ? Media.identity : "Audio"
}
Text {
anchors.centerIn: parent
visible: !Media.hasPlayer
text: "NO SIGNAL"
color: Theme.muted
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsBody
font.letterSpacing: Theme.tracking * 2
renderType: Text.NativeRendering
}
Item {
anchors.top: playHead.bottom
anchors.topMargin: Theme.padM
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: Media.hasPlayer
ClippingRectangle {
id: artFrame
width: 96
height: 96
radius: Theme.radiusL
color: Theme.surface
border.width: 1
border.color: Theme.overlay
Image {
id: art
anchors.fill: parent
anchors.margins: 1
source: Media.artUrl
fillMode: Image.PreserveAspectCrop
asynchronous: true
sourceSize.width: 192
sourceSize.height: 192
}
Icon {
anchors.centerIn: parent
visible: art.status !== Image.Ready
text: "󰝚"
color: Theme.muted
font.pixelSize: Theme.fsHuge
}
}
Column {
anchors.left: artFrame.right
anchors.leftMargin: Theme.padM
anchors.right: parent.right
anchors.verticalCenter: artFrame.verticalCenter
spacing: 3
Text {
width: parent.width
text: Media.title || "Untitled"
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.Wrap
color: Theme.accent
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsLarge
font.weight: Theme.weightDisplay
renderType: Text.NativeRendering
}
Text {
width: parent.width
text: Media.artist
elide: Text.ElideRight
color: Theme.subtext
font.family: Theme.fontMono
font.pixelSize: Theme.fsSmall
renderType: Text.NativeRendering
}
}
// Position: a hairline with a square riding it.
Item {
id: timeline
anchors.top: artFrame.bottom
anchors.topMargin: Theme.padM
anchors.left: parent.left
anchors.right: parent.right
height: Theme.marker
visible: Media.hasTimeline
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 1
color: Theme.overlay
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width * Media.progress
height: 1
color: Theme.accent
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
x: parent.width * Media.progress - width / 2
width: Theme.marker
height: Theme.marker
radius: width / 2
color: Theme.accent
}
}
Row {
anchors.bottom: parent.bottom
anchors.left: parent.left
spacing: Theme.padS
Transport {
glyph: "󰒮"
enabled: Media.canGoPrevious
onClicked: Media.previous()
}
Transport {
glyph: Media.playing ? "󰏤" : "󰐊"
onClicked: Media.playPause()
}
Transport {
glyph: "󰒭"
enabled: Media.canGoNext
onClicked: Media.next()
}
}
Text {
anchors.bottom: parent.bottom
anchors.bottomMargin: 8
anchors.right: parent.right
visible: Media.hasTimeline
text: Media.timeString(Media.position) + " / " + Media.timeString(Media.length)
color: Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
renderType: Text.NativeRendering
}
}
}
}
// A hairline, then the switches you reach for most.
Rectangle {
id: togglesRule
x: root.pad
y: columns.y + columns.implicitHeight + Theme.padL
width: root.bodyWidth - root.pad * 2
height: 1
color: Theme.alpha(Theme.outline, 0.5)
}
Row {
id: toggles
x: root.pad
y: togglesRule.y + 1 + root.pad
spacing: Theme.padS
readonly property int count: nightLight.available ? 5 : 4
readonly property real chipWidth:
(root.bodyWidth - root.pad * 2 - toggles.spacing * (toggles.count - 1)) / toggles.count
Toggle {
width: toggles.chipWidth
glyph: Net.wifiEnabled ? "󰖩" : "󰖪"
label: "Wi-Fi"
on: Net.wifiEnabled
onClicked: Net.toggleWifi()
}
Toggle {
width: toggles.chipWidth
enabled: Bt.available
glyph: Bt.enabled ? "󰂯" : "󰂲"
label: "Bluetooth"
on: Bt.enabled
onClicked: Bt.toggle()
}
Toggle {
width: toggles.chipWidth
glyph: Notifs.dnd ? "󰂛" : "󰂚"
label: "Do not disturb"
on: Notifs.dnd
onClicked: Notifs.toggleDnd()
}
Toggle {
width: toggles.chipWidth
glyph: "󰅶"
label: "Stay awake"
on: Idle.stayAwake
onClicked: Idle.toggleStayAwake()
}
Toggle {
width: toggles.chipWidth
visible: nightLight.available
glyph: "󰖔"
label: "Night light"
on: nightLight.running
onClicked: nightLight.running = !nightLight.running
}
}
}
}
}
// ------------------------------------------------------------ columns
// A vertical hairline between columns, the same rule the bar divides its
// modules with, at the drawer's scale.
component Rule: Item {
implicitWidth: Theme.padL * 2 + 1
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.topMargin: Theme.padS
anchors.bottomMargin: Theme.padS
width: 1
color: Theme.alpha(Theme.outline, 0.5)
}
}
readonly property real columnHeight: 214
// A warm screen for the evening, through hyprsunset if it is installed.
// It runs for as long as night light is on; stopping it restores the
// screen. The chip only appears when the binary exists.
Process {
id: nightLight
property bool available: false
command: ["hyprsunset", "--temperature", "4200"]
}
Process {
running: true
command: ["sh", "-c", "command -v hyprsunset"]
onExited: code => nightLight.available = code === 0
}
// A switch: a pill with a glyph and a name. On is the light slab with ink
// type, the same as a selection everywhere else; off is a hairline.
component Toggle: Item {
id: tg
property string glyph: ""
property string label: ""
property bool on: false
signal clicked()
height: 38
opacity: tg.enabled ? 1 : 0.35
Rectangle {
anchors.fill: parent
radius: height / 2
color: tg.on ? Theme.accentSoft : (tgHover.hovered ? Theme.treatmentHoverFill : "transparent")
border.width: tg.on ? 0 : 1
border.color: tgHover.hovered ? Theme.treatmentHoverMark : Theme.overlay
Behavior on color { ColorAnimation { duration: Theme.durBase } }
}
Row {
anchors.centerIn: parent
spacing: Theme.padS
Icon {
anchors.verticalCenter: parent.verticalCenter
text: tg.glyph
font.pixelSize: Theme.fsLarge
color: tg.on ? Theme.ink : (tgHover.hovered ? Theme.accent : Theme.text)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: tg.label.toUpperCase()
color: tg.on ? Theme.ink : Theme.subtext
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsMicro
font.weight: Font.DemiBold
font.letterSpacing: Theme.tracking
renderType: Text.NativeRendering
}
}
HoverHandler {
id: tgHover
enabled: tg.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: tg.enabled
onTapped: tg.clicked()
}
}
// A label and its reading on one line, label left, reading right.
component Fact: Item {
property string label: ""
property string value: ""
width: parent.width
implicitHeight: 14
Text {
anchors.left: parent.left
text: parent.label
color: Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
Text {
anchors.right: parent.right
text: parent.value
color: Theme.text
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
renderType: Text.NativeRendering
}
}
// A round hairline button. Hover lifts it; nothing here inverts, since
// inversion is reserved for the keyboard's cursor.
component Transport: Item {
id: tb
property string glyph: ""
signal clicked()
width: 30
height: 30
opacity: tb.enabled ? 1 : 0.35
Rectangle {
anchors.fill: parent
radius: width / 2
color: tbHover.hovered ? Theme.treatmentHoverFill : "transparent"
border.width: 1
border.color: tbHover.hovered ? Theme.treatmentHoverMark : Theme.overlay
Behavior on color { ColorAnimation { duration: Theme.durFast } }
}
Icon {
anchors.centerIn: parent
text: tb.glyph
color: tbHover.hovered ? Theme.accent : Theme.text
}
HoverHandler {
id: tbHover
enabled: tb.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: tb.enabled
onTapped: tb.clicked()
}
}
}