quickshell: add initial bar config with per-monitor workspaces

- Vertical bar on DP-2 with rounded-square pills throughout
- Per-monitor workspace groups sorted by screen x position, with
  Nerd Font icons for named workspaces and apex-neon red active indicator
- Bar layout: datetime+weather top, workspaces centered, gamemode+media+notif+system bottom
- Popouts anchor to triggering icon (top-right for datetime/weather, bottom-right for media/notif/system)
- Lock command switched from hyprlock to swaylock
- Hyprland blur/ignore_alpha layerrules for quickshell namespace

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 20:00:54 +02:00
parent 6e55544c42
commit c5f7162ebb
31 changed files with 3691 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import Quickshell.Hyprland
import QtQuick
import "../shared" as Shared
// Compact active window title, rotated vertically to fit the narrow bar
Item {
id: root
readonly property string title: {
let w = Hyprland.focusedWindow;
if (!w) return "";
let t = w.title || "";
let c = w.wlClass || "";
// Show class if title is too long or empty
if (t.length === 0) return c;
if (t.length > 30) return c || t.substring(0, 20);
return t;
}
visible: title !== ""
implicitWidth: Shared.Theme.barInnerWidth
implicitHeight: Math.min(label.implicitWidth + 8, 120)
Text {
id: label
anchors.centerIn: parent
rotation: -90
width: root.implicitHeight
text: root.title
color: Shared.Theme.subtext0
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
Behavior on text {
enabled: false // no animation on text change
}
}
opacity: title !== "" ? 0.7 : 0
Behavior on opacity { NumberAnimation { duration: Shared.Theme.animFast } }
}

View File

@@ -0,0 +1,52 @@
import QtQuick
import QtQuick.Layouts
import "../shared" as Shared
// Unified segmented-control pill for bar group buttons
Item {
id: root
property string groupName: ""
property color accentColor: Shared.Theme.text
property alias content: contentArea.data
readonly property bool isActive: Shared.PopoutState.active === groupName
readonly property bool isHovered: mouse.containsMouse
implicitWidth: Shared.Theme.barInnerWidth
implicitHeight: pill.height
Rectangle {
id: pill
width: parent.width
height: contentArea.implicitHeight + Shared.Theme.barPadding * 2 + 6
radius: Shared.Theme.radiusNormal
color: root.isActive
? Qt.alpha(root.accentColor, Shared.Theme.opacityLight)
: root.isHovered
? Shared.Theme.surface1
: Shared.Theme.surface0
border.width: root.isActive ? 1 : 0
border.color: Qt.alpha(root.accentColor, Shared.Theme.opacityMedium)
Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } }
Behavior on border.color { ColorAnimation { duration: Shared.Theme.animFast } }
ColumnLayout {
id: contentArea
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: 2
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
let globalPos = root.mapToItem(null, 0, root.height / 2);
Shared.PopoutState.toggle(root.groupName, globalPos.y);
}
}
}

View File

@@ -0,0 +1,37 @@
import Quickshell
import QtQuick
import QtQuick.Layouts
import "../shared" as Shared
BarPill {
groupName: "datetime"
accentColor: Shared.Theme.teal
content: [
Text {
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
text: Qt.formatDateTime(Shared.Time.date, Shared.Config.pillTimeFormat)
color: Shared.Theme.teal
font.pixelSize: Shared.Theme.fontLarge
font.family: Shared.Theme.fontFamily
font.bold: true
lineHeight: 1.1
},
Rectangle {
Layout.alignment: Qt.AlignHCenter
width: Shared.Theme.barInnerWidth * 0.5
height: 1
color: Shared.Theme.teal
opacity: Shared.Theme.opacityLight
},
Text {
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
text: Qt.formatDateTime(Shared.Time.date, Shared.Config.pillDateFormat)
color: Shared.Theme.teal
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
}
]
}

View File

@@ -0,0 +1,48 @@
import Quickshell
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
import "../shared" as Shared
// Gamemode indicator — visible only when gamemode is active
// Polls `gamemoded --status` every 5 seconds
BarPill {
id: root
groupName: "" // no popout
accentColor: Shared.Theme.green
visible: isActive
property bool isActive: false
content: [
Text {
Layout.alignment: Qt.AlignHCenter
text: "\u{f0522}" // nf-md-controller_classic
color: Shared.Theme.green
font.pixelSize: Shared.Theme.fontLarge
font.family: Shared.Theme.iconFont
}
]
Process {
id: gameProc
command: ["gamemoded", "--status"]
stdout: StdioCollector {
onStreamFinished: {
root.isActive = this.text.indexOf("is active") >= 0
}
}
}
function poll() { gameProc.running = false; gameProc.running = true; }
Timer {
interval: 5000
running: true
repeat: true
onTriggered: root.poll()
}
Component.onCompleted: root.poll()
}

View File

@@ -0,0 +1,35 @@
import Quickshell
import Quickshell.Services.Mpris
import QtQuick
import QtQuick.Layouts
import "../shared" as Shared
BarPill {
id: root
readonly property var player: {
let players = Mpris.players.values;
for (let i = 0; i < players.length; i++) {
if (players[i].isPlaying) return players[i];
}
return players.length > 0 ? players[0] : null;
}
visible: player !== null
groupName: "media"
accentColor: Shared.Theme.green
content: [
Text {
Layout.alignment: Qt.AlignHCenter
text: {
if (!root.player) return "\u{f075a}";
if (root.player.isPlaying) return "\u{f040a}";
return "\u{f03e4}";
}
color: Shared.Theme.green
font.pixelSize: Shared.Theme.fontLarge
font.family: Shared.Theme.iconFont
}
]
}

View File

@@ -0,0 +1,19 @@
import Quickshell
import QtQuick
import QtQuick.Layouts
import "../shared" as Shared
BarPill {
groupName: "system"
accentColor: Shared.Theme.lavender
content: [
Text {
Layout.alignment: Qt.AlignHCenter
text: "\u{f303}"
color: Shared.Theme.lavender
font.pixelSize: Shared.Theme.fontLarge + 2
font.family: Shared.Theme.iconFont
}
]
}

View File

@@ -0,0 +1,33 @@
import Quickshell
import QtQuick
import QtQuick.Layouts
import "../shared" as Shared
BarPill {
groupName: "weather"
accentColor: Shared.Weather.status === "error" ? Shared.Theme.overlay0 : Shared.Theme.peach
content: [
Text {
Layout.alignment: Qt.AlignHCenter
text: Shared.Weather.status === "error" ? "\u{f0599}" : Shared.Weather.icon
color: Shared.Weather.status === "error" ? Shared.Theme.overlay0
: Shared.Weather.status === "stale" ? Shared.Theme.warning
: Shared.Theme.peach
font.pixelSize: Shared.Theme.fontLarge
font.family: Shared.Theme.iconFont
},
Text {
Layout.alignment: Qt.AlignHCenter
text: Shared.Weather.status === "error" ? "N/A"
: Shared.Weather.status === "loading" ? "…"
: Shared.Weather.temp
color: Shared.Weather.status === "error" ? Shared.Theme.overlay0
: Shared.Weather.status === "stale" ? Shared.Theme.warning
: Shared.Theme.peach
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
font.bold: true
}
]
}

View File

@@ -0,0 +1,164 @@
import Quickshell
import Quickshell.Hyprland
import QtQuick
import "../shared" as Shared
// Rounded-square workspace slots with sliding active highlight.
// Set monitorName to show only workspaces on a specific monitor.
Item {
id: root
property string monitorName: ""
property int wsCount: Shared.Config.workspaceCount
readonly property int cellSize: Shared.Theme.barInnerWidth - Shared.Theme.barPadding * 2
readonly property var iconMap: ({
"mail": "\u{eb1c}",
"comms": "\u{ee59}",
"element": "\u{f1d7}",
"joplin": "\u{f249}",
"steam": "\u{f1b6}",
"spotify": "\u{f1bc}"
})
// { id, name } objects for workspaces on this monitor, sorted by name numerically.
property var monitorWsData: {
if (!monitorName) return [];
let all = Hyprland.workspaces, data = [];
for (let i = 0; i < all.values.length; i++) {
let ws = all.values[i];
if (ws.monitor && ws.monitor.name === monitorName)
data.push({ id: ws.id, name: ws.name });
}
data.sort((a, b) => parseInt(a.name) - parseInt(b.name));
return data;
}
// Active workspace ID for this monitor specifically
property int activeWsId: {
if (monitorName) {
let mons = Hyprland.monitors;
for (let i = 0; i < mons.values.length; i++) {
if (mons.values[i].name === monitorName)
return mons.values[i].activeWorkspace?.id ?? -1;
}
return -1;
}
let fw = Hyprland.focusedWorkspace;
return fw ? fw.id : 1;
}
implicitWidth: Shared.Theme.barInnerWidth
implicitHeight: container.height
Rectangle {
id: container
width: parent.width
height: wsCol.height + padding * 2
radius: Shared.Theme.radiusNormal
color: Shared.Theme.surface0
property int padding: Shared.Theme.barPadding + 2
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: event => {
if (event.angleDelta.y > 0)
Hyprland.dispatch("workspace m-1");
else
Hyprland.dispatch("workspace m+1");
}
}
// Sliding active indicator
Rectangle {
id: activeIndicator
property int targetIndex: {
if (root.monitorName) {
for (let i = 0; i < root.monitorWsData.length; i++) {
if (root.monitorWsData[i].id === root.activeWsId) return i;
}
return 0;
}
return Math.max(0, Math.min(root.activeWsId - 1, root.wsCount - 1));
}
property var targetItem: wsRepeater.itemAt(targetIndex)
x: (container.width - width) / 2
y: targetItem ? targetItem.y + wsCol.y : container.padding
width: root.cellSize
height: root.cellSize
radius: Shared.Theme.radiusSmall
color: Shared.Theme.red
Behavior on y {
NumberAnimation {
duration: Shared.Theme.animSlow
easing.type: Easing.InOutQuart
}
}
}
Column {
id: wsCol
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: container.padding
spacing: Math.floor(Shared.Theme.spacing / 2)
Repeater {
id: wsRepeater
model: root.monitorName ? root.monitorWsData.length : root.wsCount
delegate: Item {
id: wsItem
required property int index
width: root.cellSize
height: root.cellSize
property int wsId: root.monitorName ? root.monitorWsData[index].id : index + 1
property string wsName: root.monitorName ? root.monitorWsData[index].name : (index + 1).toString()
property bool isActive: root.activeWsId === wsId
property bool isOccupied: {
let all = Hyprland.workspaces;
for (let i = 0; i < all.values.length; i++) {
if (all.values[i].id === wsId)
return all.values[i].lastIpcObject?.windows > 0;
}
return false;
}
Text {
anchors.centerIn: parent
property string icon: root.iconMap[wsItem.wsName] ?? ""
property bool hasIcon: icon !== ""
text: hasIcon ? icon : wsItem.wsName
color: wsItem.isActive
? Shared.Theme.crust
: wsItem.isOccupied
? Shared.Theme.text
: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontLarge
font.family: hasIcon ? Shared.Theme.iconFont : Shared.Theme.fontFamily
font.bold: !hasIcon && wsItem.isActive
Behavior on color {
ColorAnimation { duration: Shared.Theme.animFast }
}
}
MouseArea {
anchors.fill: parent
onClicked: Hyprland.dispatch("workspace name:" + wsItem.wsName)
}
}
}
}
}
}

View File

@@ -0,0 +1,189 @@
import QtQuick
import QtQuick.Layouts
import "../../shared" as Shared
Item {
id: root
implicitWidth: Shared.Theme.popoutWidth
implicitHeight: col.implicitHeight + Shared.Theme.popoutPadding * 2
PopoutBackground { anchors.fill: parent }
property int viewMonth: new Date().getMonth()
property int viewYear: new Date().getFullYear()
property int todayDay: Shared.Time.date.getDate()
property int todayMonth: Shared.Time.date.getMonth()
property int todayYear: Shared.Time.date.getFullYear()
readonly property bool isViewingToday: viewMonth === todayMonth && viewYear === todayYear
function prevMonth() { if (viewMonth === 0) { viewMonth = 11; viewYear--; } else viewMonth--; }
function nextMonth() { if (viewMonth === 11) { viewMonth = 0; viewYear++; } else viewMonth++; }
function goToday() { viewMonth = todayMonth; viewYear = todayYear; }
function daysInMonth(m, y) { return new Date(y, m + 1, 0).getDate(); }
function firstDayOfWeek(m, y) {
let d = new Date(y, m, 1).getDay();
if (Shared.Config.weekStartsMonday)
return d === 0 ? 6 : d - 1;
return d;
}
MouseArea {
anchors.fill: parent
onWheel: event => {
if (event.angleDelta.y > 0) root.prevMonth();
else root.nextMonth();
}
}
ColumnLayout {
id: col
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Shared.Theme.popoutPadding
spacing: Shared.Theme.popoutSpacing
Text {
text: Shared.Time.clockSeconds
color: Shared.Theme.text
font.pixelSize: 28
font.family: Shared.Theme.fontFamily
font.bold: true
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 2
}
Text {
text: Qt.formatDateTime(Shared.Time.date, Shared.Config.dateFormat)
color: Shared.Theme.subtext0
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
Layout.alignment: Qt.AlignHCenter
}
Rectangle {
Layout.fillWidth: true
Layout.leftMargin: 4
Layout.rightMargin: 4
Layout.topMargin: 4
Layout.bottomMargin: 4
height: 1
color: Shared.Theme.surface0
}
RowLayout {
Layout.fillWidth: true
Text {
text: "\u{f0141}"
color: navPrev.containsMouse ? Shared.Theme.text : Shared.Theme.subtext0
font.pixelSize: 16
font.family: Shared.Theme.iconFont
MouseArea { id: navPrev; anchors.fill: parent; hoverEnabled: true; onClicked: root.prevMonth() }
}
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: new Date(root.viewYear, root.viewMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy")
color: Shared.Theme.text
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
font.bold: true
MouseArea { anchors.fill: parent; onClicked: root.goToday() }
}
Rectangle {
visible: !root.isViewingToday
implicitWidth: todayLabel.implicitWidth + 10
implicitHeight: 20
radius: 10
color: todayMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0
Behavior on color { ColorAnimation { duration: 100 } }
Text {
id: todayLabel
anchors.centerIn: parent
text: "Today"
color: Shared.Theme.blue
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
}
MouseArea { id: todayMouse; anchors.fill: parent; hoverEnabled: true; onClicked: root.goToday() }
}
Text {
text: "\u{f0142}"
color: navNext.containsMouse ? Shared.Theme.text : Shared.Theme.subtext0
font.pixelSize: 16
font.family: Shared.Theme.iconFont
MouseArea { id: navNext; anchors.fill: parent; hoverEnabled: true; onClicked: root.nextMonth() }
}
}
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: Shared.Config.dayHeaders
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: modelData
color: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
}
}
}
Grid {
id: calGrid
Layout.fillWidth: true
columns: 7
spacing: 0
property int numDays: root.daysInMonth(root.viewMonth, root.viewYear)
property int startDay: root.firstDayOfWeek(root.viewMonth, root.viewYear)
Repeater {
model: calGrid.startDay + calGrid.numDays + (7 - (calGrid.startDay + calGrid.numDays) % 7) % 7
Item {
required property int index
property int day: index - calGrid.startDay + 1
property bool isValid: day >= 1 && day <= calGrid.numDays
property bool isToday: isValid && day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear
property bool isWeekend: {
let col = index % 7;
if (Shared.Config.weekStartsMonday)
return col >= 5; // Sa=5, Su=6
return col === 0 || col === 6; // Su=0, Sa=6
}
width: calGrid.width / 7
height: 28
Rectangle {
anchors.centerIn: parent
width: 24; height: 24; radius: 12
color: parent.isToday ? Shared.Theme.blue : "transparent"
}
Text {
anchors.centerIn: parent
text: parent.isValid ? parent.day.toString() : ""
color: parent.isToday ? Shared.Theme.crust
: parent.isWeekend && parent.isValid ? Shared.Theme.overlay0
: parent.isValid ? Shared.Theme.text
: "transparent"
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
font.bold: parent.isToday
}
}
}
}
}
}

View File

@@ -0,0 +1,240 @@
import Quickshell
import Quickshell.Services.Mpris
import QtQuick
import QtQuick.Layouts
import "../../shared" as Shared
Item {
id: root
implicitWidth: Shared.Theme.popoutWidth
implicitHeight: col.implicitHeight + Shared.Theme.popoutPadding * 2
PopoutBackground { anchors.fill: parent }
MouseArea { anchors.fill: parent }
readonly property var player: {
let players = Mpris.players.values;
for (let i = 0; i < players.length; i++) {
if (players[i].isPlaying) return players[i];
}
return players.length > 0 ? players[0] : null;
}
readonly property bool isPlaying: player?.isPlaying ?? false
readonly property string trackTitle: player?.trackTitle ?? ""
readonly property string trackArtist: player?.trackArtist ?? ""
readonly property string trackAlbum: player?.trackAlbum ?? ""
readonly property string artUrl: player?.trackArtUrl ?? ""
readonly property real trackLength: player?.length ?? 0
readonly property real trackPosition: player?.position ?? 0
// Position doesn't auto-update — emit positionChanged periodically while playing
Timer {
interval: 1000
running: root.isPlaying && root.player !== null
repeat: true
onTriggered: { if (root.player) root.player.positionChanged(); }
}
ColumnLayout {
id: col
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Shared.Theme.popoutPadding
spacing: 10
// Album art
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: artImg.hasArt ? width * 0.6 : 48
radius: Shared.Theme.radiusSmall
color: Shared.Theme.surface0
clip: true
Behavior on Layout.preferredHeight { NumberAnimation { duration: Shared.Theme.animNormal; easing.type: Easing.OutCubic } }
Image {
id: artImg
anchors.fill: parent
source: root.artUrl
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
visible: hasArt
property bool hasArt: status === Image.Ready && root.artUrl !== ""
}
// Gradient overlay at bottom for readability
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: parent.height * 0.4
visible: artImg.hasArt
gradient: Gradient {
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 1.0; color: Qt.alpha(Shared.Theme.mantle, 0.8) }
}
}
// Placeholder icon when no art
Text {
anchors.centerIn: parent
visible: !artImg.hasArt
text: "\u{f075a}"
color: Shared.Theme.overlay0
font.pixelSize: 24
font.family: Shared.Theme.iconFont
}
}
// Track info
Text {
text: root.trackTitle || "No track"
color: Shared.Theme.text
font.pixelSize: Shared.Theme.fontLarge
font.family: Shared.Theme.fontFamily
font.bold: true
Layout.fillWidth: true
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
visible: text !== ""
text: root.trackArtist
color: Shared.Theme.subtext0
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
Layout.fillWidth: true
elide: Text.ElideRight
Layout.topMargin: -6
}
// Progress bar
Rectangle {
Layout.fillWidth: true
implicitHeight: 4
radius: 2
color: Shared.Theme.surface0
visible: root.trackLength > 0
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: root.trackLength > 0 ? parent.width * Math.min(1, root.trackPosition / root.trackLength) : 0
radius: 2
color: Shared.Theme.green
}
MouseArea {
anchors.fill: parent
onClicked: event => {
if (root.player?.canSeek && root.trackLength > 0) {
let ratio = event.x / parent.width;
let target = ratio * root.trackLength;
root.player.position = target;
}
}
}
}
// Time labels
RowLayout {
Layout.fillWidth: true
visible: root.trackLength > 0
Text {
text: formatTime(root.trackPosition)
color: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
}
Item { Layout.fillWidth: true }
Text {
text: formatTime(root.trackLength)
color: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
}
}
// Controls
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 16
// Previous
Text {
text: "\u{f04ae}"
color: prevMouse.containsMouse ? Shared.Theme.text : Shared.Theme.subtext0
font.pixelSize: 20
font.family: Shared.Theme.iconFont
opacity: root.player?.canGoPrevious ? 1.0 : 0.3
MouseArea {
id: prevMouse
anchors.fill: parent
hoverEnabled: true
onClicked: { if (root.player?.canGoPrevious) root.player.previous(); }
}
}
// Play/Pause
Rectangle {
implicitWidth: 40
implicitHeight: 40
radius: 20
color: playMouse.containsMouse ? Shared.Theme.green : Qt.alpha(Shared.Theme.green, Shared.Theme.opacityMedium)
Behavior on color { ColorAnimation { duration: 100 } }
Text {
anchors.centerIn: parent
text: root.isPlaying ? "\u{f03e4}" : "\u{f040a}"
color: Shared.Theme.crust
font.pixelSize: 20
font.family: Shared.Theme.iconFont
}
MouseArea {
id: playMouse
anchors.fill: parent
hoverEnabled: true
onClicked: { if (root.player?.canTogglePlaying) root.player.togglePlaying(); }
}
}
// Next
Text {
text: "\u{f04ad}"
color: nextMouse.containsMouse ? Shared.Theme.text : Shared.Theme.subtext0
font.pixelSize: 20
font.family: Shared.Theme.iconFont
opacity: root.player?.canGoNext ? 1.0 : 0.3
MouseArea {
id: nextMouse
anchors.fill: parent
hoverEnabled: true
onClicked: { if (root.player?.canGoNext) root.player.next(); }
}
}
}
// Player name
Text {
Layout.alignment: Qt.AlignHCenter
text: root.player?.identity ?? ""
color: Shared.Theme.surface2
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
}
}
function formatTime(secs) {
let m = Math.floor(secs / 60);
let s = Math.floor(secs % 60);
return m + ":" + (s < 10 ? "0" : "") + s;
}
}

View File

@@ -0,0 +1,352 @@
import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
import "../../shared" as Shared
Item {
id: root
required property var trackedNotifications
required property var daemon
function relativeTime(id) {
let ts = root.daemon?.timestamps?.[id];
if (!ts) return "";
let diff = Math.floor((Date.now() - ts) / 1000);
if (diff < 60) return "now";
if (diff < 3600) return Math.floor(diff / 60) + "m";
if (diff < 86400) return Math.floor(diff / 3600) + "h";
return Math.floor(diff / 86400) + "d";
}
implicitWidth: Shared.Theme.popoutWidth
implicitHeight: Math.min(600, col.implicitHeight + Shared.Theme.popoutPadding * 2)
PopoutBackground { anchors.fill: parent }
MouseArea { anchors.fill: parent }
// Drives relative timestamp re-evaluation
Timer { id: tsRefresh; property int tick: 0; interval: 30000; running: true; repeat: true; onTriggered: tick++ }
// DnD auto-off timer
Timer {
id: dndTimer
property int remaining: 0 // seconds, 0 = indefinite
interval: 1000
running: false
repeat: true
onTriggered: {
remaining--;
if (remaining <= 0) {
running = false;
if (root.daemon) root.daemon.dnd = false;
}
}
}
ColumnLayout {
id: col
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Shared.Theme.popoutPadding
spacing: 8
// Header
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "Notifications"
color: Shared.Theme.text
font.pixelSize: Shared.Theme.fontLarge
font.family: Shared.Theme.fontFamily
font.bold: true
Layout.fillWidth: true
}
// DnD toggle
Rectangle {
implicitWidth: dndRow.implicitWidth + 12
implicitHeight: 24
radius: 12
color: root.daemon?.dnd ? Qt.alpha(Shared.Theme.danger, Shared.Theme.opacityLight) : (dndMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0)
border.width: root.daemon?.dnd ? 1 : 0
border.color: Qt.alpha(Shared.Theme.danger, Shared.Theme.opacityMedium)
Behavior on color { ColorAnimation { duration: 100 } }
RowLayout {
id: dndRow
anchors.centerIn: parent
spacing: 4
Text { text: root.daemon?.dnd ? "\u{f009b}" : "\u{f009a}"; color: root.daemon?.dnd ? Shared.Theme.danger : Shared.Theme.overlay0; font.pixelSize: 12; font.family: Shared.Theme.iconFont }
Text { text: "DnD"; color: root.daemon?.dnd ? Shared.Theme.danger : Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSmall; font.family: Shared.Theme.fontFamily }
}
MouseArea {
id: dndMouse
anchors.fill: parent
hoverEnabled: true
onClicked: { if (root.daemon) root.daemon.dnd = !root.daemon.dnd; }
}
}
// Clear all
Rectangle {
visible: root.trackedNotifications.values.length > 0
implicitWidth: clearRow.implicitWidth + 12
implicitHeight: 24
radius: 12
color: clearMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0
Behavior on color { ColorAnimation { duration: 100 } }
RowLayout {
id: clearRow
anchors.centerIn: parent
spacing: 4
Text { text: "Clear all"; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSmall; font.family: Shared.Theme.fontFamily }
}
MouseArea {
id: clearMouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
let tracked = root.trackedNotifications;
for (let i = tracked.values.length - 1; i >= 0; i--)
tracked.values[i].dismiss();
}
}
}
}
// DnD schedule (visible when DnD is active)
Loader {
Layout.fillWidth: true
active: root.daemon?.dnd === true
visible: active
sourceComponent: RowLayout {
spacing: 4
Repeater {
model: [
{ label: "30m", mins: 30 },
{ label: "1h", mins: 60 },
{ label: "2h", mins: 120 },
{ label: "\u{f0026}", mins: 0 } // infinity = until manual off
]
Rectangle {
required property var modelData
required property int index
readonly property bool isActive: {
if (modelData.mins === 0) return dndTimer.remaining <= 0;
return dndTimer.remaining > 0 && dndTimer.remaining <= modelData.mins * 60;
}
Layout.fillWidth: true
implicitHeight: 22
radius: 11
color: isActive ? Qt.alpha(Shared.Theme.danger, Shared.Theme.opacityLight) : (schedMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0)
Behavior on color { ColorAnimation { duration: 100 } }
Text {
anchors.centerIn: parent
text: parent.modelData.label
color: parent.isActive ? Shared.Theme.danger : Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
}
MouseArea {
id: schedMouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (parent.modelData.mins === 0) {
dndTimer.remaining = 0; // indefinite
} else {
dndTimer.remaining = parent.modelData.mins * 60;
dndTimer.running = true;
}
}
}
}
}
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0 }
// Empty state
Text {
visible: root.trackedNotifications.values.length === 0
text: "No notifications"
color: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 20
Layout.bottomMargin: 20
}
// Notification list
Flickable {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(480, notifList.implicitHeight)
contentHeight: notifList.implicitHeight
clip: true
visible: root.trackedNotifications.values.length > 0
ColumnLayout {
id: notifList
width: parent.width
spacing: 6
Repeater {
model: root.trackedNotifications
Rectangle {
id: notifCard
required property var modelData
Layout.fillWidth: true
implicitHeight: notifContent.implicitHeight + 16
radius: Shared.Theme.radiusSmall
color: notifMouse.containsMouse ? Shared.Theme.surface1 : Shared.Theme.surface0
border.width: modelData.urgency === NotificationUrgency.Critical ? 1 : 0
border.color: Shared.Theme.danger
Behavior on color { ColorAnimation { duration: 100 } }
MouseArea {
id: notifMouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
let actions = notifCard.modelData.actions;
if (actions.length > 0)
actions[0].invoke();
else
notifCard.modelData.dismiss();
}
}
ColumnLayout {
id: notifContent
anchors.fill: parent
anchors.margins: 8
spacing: 4
// App name + close
RowLayout {
Layout.fillWidth: true
spacing: 6
IconImage {
source: notifCard.modelData.appIcon
implicitSize: 14
visible: notifCard.modelData.appIcon !== ""
}
Text {
text: notifCard.modelData.appName || "App"
color: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
Layout.fillWidth: true
elide: Text.ElideRight
}
Text {
property int tick: tsRefresh.tick
text: root.relativeTime(notifCard.modelData.id)
color: Shared.Theme.surface2
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
visible: text !== ""
}
Text {
text: "\u{f0156}"
color: dismissMouse.containsMouse ? Shared.Theme.danger : Shared.Theme.overlay0
font.pixelSize: 12
font.family: Shared.Theme.iconFont
MouseArea {
id: dismissMouse
anchors.fill: parent
hoverEnabled: true
onClicked: notifCard.modelData.dismiss()
}
}
}
// Summary
Text {
visible: text !== ""
text: notifCard.modelData.summary
color: Shared.Theme.text
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
font.bold: true
Layout.fillWidth: true
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
}
// Body
Text {
visible: text !== ""
text: notifCard.modelData.body
textFormat: Text.PlainText
color: Shared.Theme.subtext0
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
}
// Actions
RowLayout {
visible: notifCard.modelData.actions.length > 0
Layout.fillWidth: true
spacing: 4
Repeater {
model: notifCard.modelData.actions
Rectangle {
required property var modelData
Layout.fillWidth: true
implicitHeight: 24
radius: 4
color: actMouse.containsMouse ? Shared.Theme.surface2 : Shared.Theme.surface1
Text {
anchors.centerIn: parent
text: modelData.text
color: Shared.Theme.text
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
}
MouseArea {
id: actMouse
anchors.fill: parent
hoverEnabled: true
onClicked: modelData.invoke()
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
import QtQuick
import "../../shared" as Shared
// Rounded left corners, square right (flush against bar)
Item {
anchors.fill: parent
clip: true
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width + 22
radius: 22
color: Shared.Theme.popoutBackground
border.width: 1
border.color: Shared.Theme.borderSubtle
}
}

View File

@@ -0,0 +1,840 @@
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
import QtQuick.Window
import "../../shared" as Shared
Item {
id: root
implicitWidth: Shared.Theme.popoutWidth
implicitHeight: Math.min(flickable.contentHeight + Shared.Theme.popoutPadding * 2, Screen.height - 32)
PopoutBackground { anchors.fill: parent }
// --- State ---
readonly property int historySize: 30
property var cpuHistory: []
property var memHistory: []
property var gpuHistory: []
function pushHistory(arr, val) {
let a = arr.slice();
a.push(val);
if (a.length > historySize) a.shift();
return a;
}
property real cpuVal: 0
property string memText: "--"
property real memVal: 0
property string tempText: "--"
property int tempVal: 0
property string gpuText: "--"
property real gpuUsage: 0
property int gpuTemp: 0
property string nvmeTempText: "--"
property int nvmeTempVal: 0
property string diskRootText: "--"
property real diskRootVal: 0
property string diskDataText: "--"
property real diskDataVal: 0
property string updatesText: ""
property string updatesClass: ""
property string alhpText: ""
property string alhpClass: ""
property string networkIp: "--"
property string networkIface: "--"
property bool idleActive: false
property var panelWindow: null
property string audioDrawer: "" // "" = closed, "sink" or "source"
function thresholdColor(val, warn, crit) {
if (val >= crit) return Shared.Theme.danger;
if (val >= warn) return Shared.Theme.warning;
return Shared.Theme.success;
}
function tempColor(t) {
if (t >= 85) return Shared.Theme.danger;
if (t >= 70) return Shared.Theme.warning;
return Shared.Theme.success;
}
MouseArea { anchors.fill: parent }
Flickable {
id: flickable
anchors.fill: parent
anchors.margins: Shared.Theme.popoutPadding
contentHeight: col.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ColumnLayout {
id: col
width: flickable.width
spacing: 6
// ─── UPDATES ─────────────────────────
Loader {
Layout.fillWidth: true
active: root.updatesClass === "pending" || root.updatesClass === "many"
visible: active
sourceComponent: Rectangle {
implicitHeight: updRow.implicitHeight + 14
radius: Shared.Theme.radiusSmall
color: Shared.Theme.surface0
RowLayout {
id: updRow
anchors.fill: parent
anchors.margins: 8
spacing: 10
Text { text: "\u{f0ab7}"; color: Shared.Theme.warning; font.pixelSize: 14; font.family: Shared.Theme.iconFont }
Text { text: root.updatesText + " updates available"; color: Shared.Theme.text; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily; Layout.fillWidth: true }
Text { visible: root.alhpText !== ""; text: "ALHP " + root.alhpText; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily }
}
}
}
Loader {
Layout.fillWidth: true
active: root.updatesClass !== "pending" && root.updatesClass !== "many" && root.updatesClass !== ""
visible: active
sourceComponent: RowLayout {
spacing: 10
Text { text: "\u{f05e0}"; color: Shared.Theme.success; font.pixelSize: 14; font.family: Shared.Theme.iconFont }
Text { text: "System up to date"; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily }
}
}
// ─── separator ───
Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 }
// ─── STORAGE & PERFORMANCE ───────────
MetricBar { label: Shared.Config.diskMount1Label; value: root.diskRootText; fill: root.diskRootVal; barColor: thresholdColor(root.diskRootVal * 100, 70, 90); valueColor: thresholdColor(root.diskRootVal * 100, 70, 90) }
MetricBar { label: Shared.Config.diskMount2Label; value: root.diskDataText; fill: root.diskDataVal; barColor: thresholdColor(root.diskDataVal * 100, 70, 90); valueColor: thresholdColor(root.diskDataVal * 100, 70, 90) }
Item { implicitHeight: 2 }
MetricBar {
label: "CPU"
value: root.cpuVal.toFixed(0) + "%"
fill: root.cpuVal / 100
barColor: thresholdColor(root.cpuVal, 50, 80)
valueColor: thresholdColor(root.cpuVal, 50, 80)
suffix: root.tempText
suffixColor: tempColor(root.tempVal)
history: root.cpuHistory
}
MetricBar {
label: "Mem"
value: root.memText
fill: root.memVal
barColor: thresholdColor(root.memVal * 100, 60, 85)
valueColor: thresholdColor(root.memVal * 100, 60, 85)
history: root.memHistory
}
MetricBar {
label: "GPU"
value: root.gpuText
fill: root.gpuUsage / 100
barColor: thresholdColor(root.gpuUsage, 50, 80)
valueColor: thresholdColor(root.gpuUsage, 50, 80)
history: root.gpuHistory
}
MetricBar {
label: "NVMe"
value: root.nvmeTempText
fill: root.nvmeTempVal / 100
barColor: tempColor(root.nvmeTempVal)
valueColor: tempColor(root.nvmeTempVal)
}
// ─── separator ───
Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 }
// ─── AUDIO ───────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 8
VolumeSlider {
Layout.fillWidth: true
audio: Pipewire.defaultAudioSink?.audio ?? null
icon: (!audio || audio.muted) ? "\u{f057f}" : "\u{f057e}"
label: "Out"
accentColor: Shared.Theme.sky
drawerActive: root.audioDrawer === "sink"
onDrawerToggled: root.audioDrawer = root.audioDrawer === "sink" ? "" : "sink"
}
VolumeSlider {
Layout.fillWidth: true
audio: Pipewire.defaultAudioSource?.audio ?? null
icon: (!audio || audio.muted) ? "\u{f036d}" : "\u{f036c}"
label: "Mic"
accentColor: Shared.Theme.pink
drawerActive: root.audioDrawer === "source"
onDrawerToggled: root.audioDrawer = root.audioDrawer === "source" ? "" : "source"
}
}
// Device drawer
Loader {
Layout.fillWidth: true
active: root.audioDrawer !== ""
visible: active
sourceComponent: ColumnLayout {
spacing: 3
Repeater {
model: Pipewire.nodes
Rectangle {
id: devItem
required property var modelData
readonly property bool isSinkDrawer: root.audioDrawer === "sink"
readonly property bool matchesFilter: modelData.audio && !modelData.isStream
&& modelData.isSink === isSinkDrawer
readonly property bool isDefault: isSinkDrawer
? Pipewire.defaultAudioSink === modelData
: Pipewire.defaultAudioSource === modelData
visible: matchesFilter
Layout.fillWidth: true
implicitHeight: matchesFilter ? 28 : 0
radius: Shared.Theme.radiusSmall
color: devMouse.containsMouse ? Shared.Theme.surface1 : (isDefault ? Shared.Theme.surface0 : "transparent")
Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } }
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 6
Text {
text: devItem.isDefault ? "\u{f012c}" : "\u{f0765}"
color: devItem.isDefault ? (devItem.isSinkDrawer ? Shared.Theme.sky : Shared.Theme.pink) : Shared.Theme.overlay0
font.pixelSize: 12
font.family: Shared.Theme.iconFont
}
Text {
text: devItem.modelData.description || devItem.modelData.name
color: devItem.isDefault ? Shared.Theme.text : Shared.Theme.subtext0
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
font.bold: devItem.isDefault
Layout.fillWidth: true
elide: Text.ElideRight
}
}
MouseArea {
id: devMouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (devItem.isSinkDrawer)
Pipewire.preferredDefaultAudioSink = devItem.modelData;
else
Pipewire.preferredDefaultAudioSource = devItem.modelData;
root.audioDrawer = "";
}
}
}
}
}
}
// ─── separator ───
Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 }
// ─── NETWORK ─────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Text { text: "\u{f0bf0}"; color: Shared.Theme.overlay0; font.pixelSize: 14; font.family: Shared.Theme.iconFont }
Text { text: root.networkIface; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily }
Item { Layout.fillWidth: true }
Text { text: root.networkIp; color: Shared.Theme.text; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily; font.bold: true }
}
// ─── separator ───
Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 }
// ─── SYSTRAY ─────────────────────────
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: SystemTray.items
Rectangle {
id: trayItem
required property var modelData
visible: modelData.status !== Status.Passive
width: 32
height: 32
radius: Shared.Theme.radiusSmall
color: trayMouse.containsMouse ? Shared.Theme.surface1 : "transparent"
Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } }
IconImage {
anchors.centerIn: parent
source: trayItem.modelData.icon
implicitSize: 22
}
// Tooltip — positioned above icon, clamped to popout width
Rectangle {
id: tooltip
visible: trayMouse.containsMouse && tipText.text !== ""
width: Math.min(tipText.implicitWidth + 12, Shared.Theme.popoutWidth - Shared.Theme.popoutPadding * 2)
height: tipText.implicitHeight + 8
radius: 6
color: Shared.Theme.surface0
z: 10
// Position above the icon, clamp horizontally within the popout
y: -height - 4
x: {
let centered = (trayItem.width - width) / 2;
let globalX = trayItem.mapToItem(col, centered, 0).x;
let maxX = col.width - width;
let clampedGlobalX = Math.max(0, Math.min(globalX, maxX));
return centered + (clampedGlobalX - globalX);
}
Text {
id: tipText
anchors.centerIn: parent
width: parent.width - 12
text: trayItem.modelData.tooltipTitle || trayItem.modelData.title || ""
color: Shared.Theme.text
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
elide: Text.ElideRight
maximumLineCount: 1
}
}
MouseArea {
id: trayMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: event => {
let item = trayItem.modelData;
if (event.button === Qt.RightButton || item.onlyMenu) {
if (item.hasMenu && root.panelWindow) {
let pos = trayItem.mapToItem(null, trayItem.width / 2, trayItem.height / 2);
item.display(root.panelWindow, pos.x, pos.y);
}
} else if (event.button === Qt.MiddleButton) {
item.secondaryActivate();
} else {
item.activate();
}
}
onWheel: event => {
trayItem.modelData.scroll(event.angleDelta.y, false);
}
}
}
}
}
// ─── separator ───
Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 6; Layout.bottomMargin: 6 }
// ─── ACTIONS ─────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 6
ActionIcon {
icon: "\u{f1436}"
label: "Idle"
iconColor: root.idleActive ? Shared.Theme.green : Shared.Theme.overlay0
onActivated: {
if (root.idleActive) idleKill.running = true;
else idleStart.running = true;
root.idleActive = !root.idleActive;
}
}
ActionIcon {
icon: "\u{f033e}"
label: "Lock"
iconColor: Shared.Theme.subtext0
onActivated: { Shared.PopoutState.close(); lockProc.running = true; }
}
Item { Layout.fillWidth: true }
HoldAction {
icon: "\u{f0425}"
label: "Logout"
iconColor: Shared.Theme.subtext0
holdColor: Shared.Theme.green
onConfirmed: { Shared.PopoutState.close(); logoutProc.running = true; }
}
HoldAction {
icon: "\u{f0709}"
label: "Reboot"
iconColor: Shared.Theme.peach
holdColor: Shared.Theme.peach
onConfirmed: { Shared.PopoutState.close(); rebootProc.running = true; }
}
HoldAction {
icon: "\u{f0425}"
label: "Off"
iconColor: Shared.Theme.danger
holdColor: Shared.Theme.danger
onConfirmed: { Shared.PopoutState.close(); offProc.running = true; }
}
}
} // ColumnLayout
} // Flickable
// ═══════════════════════════════════════
// COMPONENTS
// ═══════════════════════════════════════
component Sparkline: Canvas {
id: spark
property var history: []
property color lineColor: Shared.Theme.overlay0
onHistoryChanged: requestPaint()
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onPaint: {
let ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
if (history.length < 2) return;
ctx.strokeStyle = Qt.alpha(lineColor, 0.5);
ctx.lineWidth = 1;
ctx.beginPath();
let step = width / (root.historySize - 1);
let offset = root.historySize - history.length;
for (let i = 0; i < history.length; i++) {
let x = (offset + i) * step;
let y = height - history[i] * height;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
}
component MetricBar: RowLayout {
property string label
property string value
property real fill: 0
property color barColor: Shared.Theme.green
property color valueColor: Shared.Theme.text
property string suffix: ""
property color suffixColor: Shared.Theme.overlay0
property var history: null
Layout.fillWidth: true
implicitHeight: 22
spacing: 10
Text {
text: parent.label
color: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
Layout.preferredWidth: 40
}
Rectangle {
Layout.fillWidth: true
implicitHeight: 10
radius: 5
color: Shared.Theme.surface0
Sparkline {
anchors.fill: parent
visible: history && history.length > 1
history: parent.parent.history ?? []
lineColor: barColor
}
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.min(1, Math.max(0, fill))
radius: 5
color: barColor
opacity: Shared.Theme.opacityFill
Behavior on width { NumberAnimation { duration: Shared.Theme.animFast; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: Shared.Theme.animNormal } }
}
}
Text {
text: parent.value
color: parent.valueColor
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
font.bold: true
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: 86
}
Loader {
active: parent.suffix !== ""
visible: active
sourceComponent: Text {
text: suffix
color: suffixColor
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
font.bold: true
}
}
}
component VolumeSlider: Rectangle {
id: vs
property var audio: null
property string icon
property string label
property color accentColor
property bool drawerActive: false
signal drawerToggled()
property real vol: 0
property bool muted: audio?.muted ?? false
function updateVol() {
if (audio && !isNaN(audio.volume) && audio.volume >= 0)
vol = audio.volume;
}
onAudioChanged: { updateVol(); bindRetry.retries = 0; bindRetry.running = true; }
Connections {
target: vs.audio
function onVolumeChanged() { vs.updateVol(); }
}
// Retry binding pickup after async PwObjectTracker re-bind
Timer {
id: bindRetry
interval: 300
running: false
repeat: true
property int retries: 0
onTriggered: {
vs.updateVol();
retries++;
if (vs.vol > 0 || retries >= 5) running = false;
}
}
Layout.fillWidth: true
implicitHeight: 36
radius: Shared.Theme.radiusSmall
color: drawerActive ? Qt.alpha(accentColor, Shared.Theme.opacityLight) : Shared.Theme.surface0
border.width: drawerActive ? 1 : 0
border.color: Qt.alpha(accentColor, Shared.Theme.opacityMedium)
opacity: muted ? 0.45 : 1.0
clip: true
Behavior on opacity { NumberAnimation { duration: Shared.Theme.animFast } }
Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } }
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: vs.muted ? 0 : parent.width * Math.min(1, vs.vol)
radius: Shared.Theme.radiusSmall
color: Qt.alpha(vs.accentColor, Shared.Theme.opacityMedium)
Behavior on width { NumberAnimation { duration: Shared.Theme.animFast; easing.type: Easing.OutCubic } }
}
RowLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
Text { text: vs.icon; color: vs.accentColor; font.pixelSize: 14; font.family: Shared.Theme.iconFont }
Text { text: vs.label; color: vs.accentColor; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily }
Item { Layout.fillWidth: true }
Text {
text: vs.muted ? "Muted" : Math.round(vs.vol * 100) + "%"
color: vs.muted ? Shared.Theme.overlay0 : Shared.Theme.text
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
font.bold: !vs.muted
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
property bool dragging: false
property real startX: 0
property int pressedBtn: Qt.NoButton
onClicked: event => {
if (event.button === Qt.MiddleButton) { pavuProc.running = true; return; }
if (event.button === Qt.RightButton) { if (vs.audio) vs.audio.muted = !vs.audio.muted; return; }
if (!dragging) vs.drawerToggled();
}
onPressed: event => { dragging = false; startX = event.x; pressedBtn = event.button; }
onPositionChanged: event => {
if (pressed && pressedBtn === Qt.LeftButton) {
if (Math.abs(event.x - startX) > 5) dragging = true;
if (dragging && vs.audio) vs.audio.volume = Math.max(0, Math.min(1, event.x / vs.width));
}
}
onWheel: event => {
if (!vs.audio) return;
let step = 0.05;
if (event.angleDelta.y > 0) vs.audio.volume = Math.min(1.0, vs.vol + step);
else vs.audio.volume = Math.max(0.0, vs.vol - step);
}
}
}
component ActionIcon: Rectangle {
property string icon
property string label
property color iconColor
signal activated()
implicitWidth: actCol.implicitWidth + 16
implicitHeight: actCol.implicitHeight + 14
radius: Shared.Theme.radiusSmall
color: actMouse.containsMouse ? Shared.Theme.surface1 : "transparent"
border.width: actMouse.containsMouse ? 1 : 0
border.color: Shared.Theme.surface2
Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } }
Column {
id: actCol
anchors.centerIn: parent
spacing: 3
Text { anchors.horizontalCenter: parent.horizontalCenter; text: icon; color: iconColor; font.pixelSize: 18; font.family: Shared.Theme.iconFont }
Text { anchors.horizontalCenter: parent.horizontalCenter; text: label; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSmall; font.family: Shared.Theme.fontFamily }
}
MouseArea { id: actMouse; anchors.fill: parent; hoverEnabled: true; onClicked: parent.activated() }
}
component HoldAction: Rectangle {
id: ha
property string icon
property string label
property color iconColor
property color holdColor: Shared.Theme.red
signal confirmed()
readonly property real holdDuration: 800
property real holdProgress: 0
property bool holding: false
implicitWidth: haCol.implicitWidth + 16
implicitHeight: haCol.implicitHeight + 14
radius: Shared.Theme.radiusSmall
color: haMouse.containsMouse ? Shared.Theme.surface1 : "transparent"
border.width: haMouse.containsMouse || holding ? 1 : 0
border.color: holding ? Qt.alpha(holdColor, Shared.Theme.opacityStrong) : Shared.Theme.surface2
Behavior on color { ColorAnimation { duration: Shared.Theme.animFast } }
// Fill overlay
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * ha.holdProgress
radius: Shared.Theme.radiusSmall
color: Qt.alpha(ha.holdColor, Shared.Theme.opacityLight)
}
Column {
id: haCol
anchors.centerIn: parent
spacing: 3
Text { anchors.horizontalCenter: parent.horizontalCenter; text: ha.icon; color: ha.holding ? ha.holdColor : ha.iconColor; font.pixelSize: 18; font.family: Shared.Theme.iconFont }
Text { anchors.horizontalCenter: parent.horizontalCenter; text: ha.holding ? "Hold…" : ha.label; color: ha.holding ? ha.holdColor : Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSmall; font.family: Shared.Theme.fontFamily }
}
Timer {
id: holdTimer
interval: 16
running: ha.holding
repeat: true
onTriggered: {
ha.holdProgress += interval / ha.holdDuration;
if (ha.holdProgress >= 1.0) {
ha.holding = false;
ha.holdProgress = 0;
ha.confirmed();
}
}
}
MouseArea {
id: haMouse
anchors.fill: parent
hoverEnabled: true
onPressed: { ha.holding = true; ha.holdProgress = 0; }
onReleased: { ha.holding = false; ha.holdProgress = 0; }
onCanceled: { ha.holding = false; ha.holdProgress = 0; }
}
}
// ═══════════════════════════════════════
// DATA FETCHING
// ═══════════════════════════════════════
property real prevCpuActive: -1
property real prevCpuTotal: -1
Process { id: cpuProc; command: ["sh", "-c", "awk '/^cpu / {print $2+$4, $2+$4+$5}' /proc/stat"]; stdout: StdioCollector {
onStreamFinished: {
let p = this.text.trim().split(" ");
let active = parseFloat(p[0]), total = parseFloat(p[1]);
if (!isNaN(active) && root.prevCpuTotal > 0) {
let da = active - root.prevCpuActive;
let dt = total - root.prevCpuTotal;
if (dt > 0) { root.cpuVal = (da / dt) * 100; root.cpuHistory = root.pushHistory(root.cpuHistory, root.cpuVal / 100); }
}
root.prevCpuActive = active;
root.prevCpuTotal = total;
}
}}
Process { id: memProc; command: ["sh", "-c", "free -m | awk '/Mem:/ {printf \"%.1f/%.0fG %.4f\", $3/1024, $2/1024, $3/$2}'"]; stdout: StdioCollector {
onStreamFinished: { let p = this.text.trim().split(" "); if (p.length >= 2) { root.memText = p[0]; root.memVal = parseFloat(p[1]) || 0; root.memHistory = root.pushHistory(root.memHistory, root.memVal); } }
}}
Process { id: tempProc; command: ["sh", "-c", "sensors 2>/dev/null | awk '/Tctl:|Tdie:|Package id 0:/ {gsub(/\\+|°C/,\"\",$2); printf \"%d\", $2; exit}'"]; stdout: StdioCollector {
onStreamFinished: { let v = parseInt(this.text.trim()); if (!isNaN(v)) { root.tempVal = v; root.tempText = v + "°C"; } }
}}
Process { id: gpuProc; command: [Shared.Config.gpuScript]; stdout: StdioCollector {
onStreamFinished: { try {
let d = JSON.parse(this.text);
let tt = d.tooltip || "";
let lines = tt.split("\n");
let usage = 0, temp = 0, power = 0;
for (let i = 0; i < lines.length; i++) {
let v = parseInt(lines[i].replace(/[^0-9]/g, ""));
if (isNaN(v)) continue;
if (lines[i].indexOf("Usage") >= 0) usage = v;
else if (lines[i].indexOf("Temp") >= 0) temp = v;
else if (lines[i].indexOf("Power") >= 0) power = v;
}
root.gpuUsage = usage; root.gpuTemp = temp;
root.gpuText = usage + "% " + temp + "°C " + power + "W";
root.gpuHistory = root.pushHistory(root.gpuHistory, usage / 100);
} catch(e) {} }
}}
Process { id: nvmeTempProc; command: ["sh", "-c", "for d in /sys/class/hwmon/hwmon*; do if grep -q nvme \"$d/name\" 2>/dev/null; then awk '{printf \"%d\", $1/1000}' \"$d/temp1_input\" 2>/dev/null; break; fi; done"]; stdout: StdioCollector {
onStreamFinished: { let v = parseInt(this.text.trim()); if (!isNaN(v) && v > 0) { root.nvmeTempVal = v; root.nvmeTempText = v + "°C"; } }
}}
Process { id: diskProc; command: ["sh", "-c",
"df -h " + Shared.Config.diskMount1 + " --output=pcent,size 2>/dev/null | tail -1 && " +
"df -h " + Shared.Config.diskMount2 + " --output=pcent,size 2>/dev/null | tail -1 && " +
"df " + Shared.Config.diskMount1 + " --output=pcent 2>/dev/null | tail -1 && " +
"df " + Shared.Config.diskMount2 + " --output=pcent 2>/dev/null | tail -1"
]; stdout: StdioCollector {
onStreamFinished: {
let lines = this.text.trim().split("\n");
if (lines.length >= 1) root.diskRootText = lines[0].trim().replace(/\s+/g, " of ");
if (lines.length >= 2) root.diskDataText = lines[1].trim().replace(/\s+/g, " of ");
if (lines.length >= 3) root.diskRootVal = parseInt(lines[2]) / 100 || 0;
if (lines.length >= 4) root.diskDataVal = parseInt(lines[3]) / 100 || 0;
}
}}
Process { id: updateProc; command: ["bash", "-c", "set -o pipefail; checkupdates 2>/dev/null | wc -l; echo \":$?\""]; stdout: StdioCollector {
onStreamFinished: {
let text = this.text.trim();
let exitMatch = text.match(/:(\d+)$/);
let exit = exitMatch ? parseInt(exitMatch[1]) : 1;
let n = parseInt(text);
// checkupdates: 0 = updates available, 2 = no updates, anything else = error
if (exit !== 0 && exit !== 2) return; // error — keep previous state
if (isNaN(n) || n === 0) { root.updatesText = ""; root.updatesClass = "uptodate"; }
else if (n > 50) { root.updatesText = n.toString(); root.updatesClass = "many"; }
else { root.updatesText = n.toString(); root.updatesClass = "pending"; }
}
}}
Process { id: alhpProc; command: ["sh", "-c", "alhp.utils -j 2>/dev/null || echo '{}'"]; stdout: StdioCollector {
onStreamFinished: {
try {
let d = JSON.parse(this.text);
let total = d.total || 0;
let stale = d.mirror_out_of_date || false;
if (stale) { root.alhpText = "stale"; root.alhpClass = "stale"; }
else if (total > 0) { root.alhpText = total.toString(); root.alhpClass = "bad"; }
else { root.alhpText = ""; root.alhpClass = "good"; }
} catch(e) { root.alhpText = "?"; root.alhpClass = "down"; }
}
}}
Process { id: netProc; command: ["sh", "-c", "ip -4 -o addr show | grep -v '" + Shared.Config.netExcludePattern + "' | head -1 | awk '{split($4,a,\"/\"); print $2 \":\" a[1]}'"]; stdout: StdioCollector {
onStreamFinished: { let l = this.text.trim(); if (l) { let p = l.split(":"); root.networkIface = p[0] || "--"; root.networkIp = p[1] || "--"; } else { root.networkIface = "Network"; root.networkIp = "Offline"; } }
}}
Process { id: pavuProc; command: ["pavucontrol"] }
Process { id: idleProc; command: ["pgrep", "-x", Shared.Config.idleProcess]; stdout: StdioCollector { onStreamFinished: root.idleActive = this.text.trim().length > 0 } }
Process { id: idleKill; command: ["killall", Shared.Config.idleProcess] }
Process { id: idleStart; command: ["sh", "-c", "setsid " + Shared.Config.idleProcess + " > /dev/null 2>&1 &"] }
Process { id: lockProc; command: [Shared.Config.lockCommand] }
Process { id: logoutProc; command: Shared.Config.powerActions[1].command }
Process { id: rebootProc; command: Shared.Config.powerActions[2].command }
Process { id: offProc; command: Shared.Config.powerActions[3].command }
function rerun(proc) { proc.running = false; proc.running = true; }
Timer { interval: 5000; running: true; repeat: true; onTriggered: { rerun(cpuProc); rerun(memProc); rerun(tempProc); rerun(gpuProc); rerun(nvmeTempProc); rerun(idleProc); } }
Timer { interval: 30000; running: true; repeat: true; onTriggered: { rerun(diskProc); rerun(netProc); } }
Timer { interval: 300000; running: true; repeat: true; onTriggered: { rerun(updateProc); rerun(alhpProc); } }
// Stagger initial launches to avoid 9 concurrent process spawns
Component.onCompleted: {
rerun(cpuProc); rerun(memProc); rerun(tempProc);
staggerTimer.running = true;
}
Timer {
id: staggerTimer
interval: 200
onTriggered: { rerun(gpuProc); rerun(nvmeTempProc); rerun(idleProc); rerun(diskProc); rerun(netProc); rerun(updateProc); rerun(alhpProc); }
}
}

View File

@@ -0,0 +1,153 @@
import Quickshell
import QtQuick
import QtQuick.Layouts
import "../../shared" as Shared
Item {
id: root
implicitWidth: Shared.Theme.popoutWidth
implicitHeight: col.implicitHeight + Shared.Theme.popoutPadding * 2
PopoutBackground { anchors.fill: parent }
MouseArea { anchors.fill: parent }
ColumnLayout {
id: col
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Shared.Theme.popoutPadding
spacing: 6
// ─── Header ───
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: Shared.Weather.location
color: Shared.Theme.text
font.pixelSize: 18
font.family: Shared.Theme.fontFamily
font.bold: true
Layout.fillWidth: true
}
Text {
text: Shared.Weather.icon
color: Shared.Theme.peach
font.pixelSize: 24
font.family: Shared.Theme.iconFont
}
Text {
text: Shared.Weather.temp
color: Shared.Theme.peach
font.pixelSize: 18
font.family: Shared.Theme.fontFamily
font.bold: true
}
}
Text {
text: {
if (Shared.Weather.status === "error") return "Unable to fetch weather data";
if (Shared.Weather.status === "stale") return Shared.Weather.description + " · stale";
if (Shared.Weather.status === "loading") return "Loading…";
return Shared.Weather.description;
}
color: Shared.Weather.status === "error" ? Shared.Theme.danger
: Shared.Weather.status === "stale" ? Shared.Theme.warning
: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
}
// ─── separator ───
Rectangle { Layout.fillWidth: true; height: 1; color: Shared.Theme.surface0; Layout.topMargin: 4; Layout.bottomMargin: 4 }
// ─── Conditions ───
ConditionRow { label: "Feels like"; value: Shared.Weather.feelsLike }
ConditionRow { label: "Humidity"; value: Shared.Weather.humidity }
ConditionRow { label: "Wind"; value: Shared.Weather.wind }
// ─── separator ───
Rectangle {
Layout.fillWidth: true
Layout.leftMargin: 4
Layout.rightMargin: 4
Layout.topMargin: 2
Layout.bottomMargin: 6
height: 1
color: Shared.Theme.surface0
}
// ─── Forecast ───
Text {
text: "Forecast"
color: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
font.bold: true
Layout.bottomMargin: 2
}
Repeater {
model: Shared.Weather.forecast
RowLayout {
required property var modelData
Layout.fillWidth: true
implicitHeight: 22
spacing: 8
Text {
text: modelData.day
color: Shared.Theme.overlay0
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
Layout.preferredWidth: 36
}
Text {
text: Shared.Weather.weatherIcon(modelData.code)
color: Shared.Theme.peach
font.pixelSize: 14
font.family: Shared.Theme.iconFont
}
Text {
text: modelData.desc
color: Shared.Theme.surface2
font.pixelSize: Shared.Theme.fontSmall
font.family: Shared.Theme.fontFamily
Layout.fillWidth: true
elide: Text.ElideRight
}
Text {
text: modelData.low + "°"
color: Shared.Theme.sky
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: 28
}
Text {
text: modelData.high + "°"
color: Shared.Theme.peach
font.pixelSize: Shared.Theme.fontSize
font.family: Shared.Theme.fontFamily
font.bold: true
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: 28
}
}
}
}
component ConditionRow: RowLayout {
property string label
property string value
Layout.fillWidth: true
implicitHeight: 22
spacing: 10
Text { text: parent.label; color: Shared.Theme.overlay0; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily; Layout.fillWidth: true }
Text { text: parent.value; color: Shared.Theme.text; font.pixelSize: Shared.Theme.fontSize; font.family: Shared.Theme.fontFamily; font.bold: true }
}
}