add quickshell bar

This commit is contained in:
Johannes Knopp
2026-04-06 01:30:28 +02:00
parent 8d95eeb892
commit c2b28df404
15 changed files with 1043 additions and 0 deletions

View File

@ -0,0 +1,84 @@
import Quickshell
import QtQuick
import "../components"
PanelWindow {
id: root
property var modelData
screen: modelData
anchors {
top: true
left: true
right: true
}
implicitHeight: 40
color: Theme.bg
readonly property string screenName: modelData.name
// Layout — three sections anchored independently for true centering
Item {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
anchors.topMargin: 2
anchors.bottomMargin: 2
// Left
Enclosure {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
child: Workspaces {
screenName: root.screenName
}
}
// Center — truly centered regardless of left/right content width
Enclosure {
anchors.centerIn: parent
child: MusicPlayer {
id: musicChip
onClicked: musicControls.visible = !musicControls.visible
}
}
// Right — status chips
Enclosure {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
child: Row {
spacing: 8
SysTray {
anchors.verticalCenter: parent.verticalCenter
barWindow: root
}
NetworkStatus {
anchors.verticalCenter: parent.verticalCenter
}
VolumeControl {
anchors.verticalCenter: parent.verticalCenter
}
Clock {
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
// Popup declared after the layout so musicChip is already initialised.
// Centered horizontally within the bar window, placed just below it.
MusicPlayerControls {
id: musicControls
visible: false
anchor.window: root
anchor.rect.x: Math.round((root.width - musicControls.width) / 2)
anchor.rect.y: root.height
player: musicChip.player
}
}

View File

@ -0,0 +1,196 @@
import Quickshell
import QtQuick
import "../components"
Item {
id: root
implicitWidth: timeLabel.implicitWidth + 10
implicitHeight: 24
property var now: new Date()
Timer {
interval: 1000
running: true
repeat: true
onTriggered: root.now = new Date()
}
readonly property string display: {
const d = now
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
const h = String(d.getHours()).padStart(2, "0")
const m = String(d.getMinutes()).padStart(2, "0")
const s = String(d.getSeconds()).padStart(2, "0")
return days[d.getDay()] + " " + h + ":" + m + ":" + s
}
Text {
id: timeLabel
anchors.centerIn: parent
text: root.display
color: Theme.text
font.pixelSize: 12
font.family: "JetBrainsMono Nerd Font Mono"
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: calPopup.visible = !calPopup.visible
}
// --------------- Calendar popup ---------------
PopupWindow {
id: calPopup
visible: false
anchor.item: root
anchor.edges: Edges.Bottom
anchor.gravity: Edges.Bottom
// calendar state
property int viewYear: root.now.getFullYear()
property int viewMonth: root.now.getMonth() // 0-based
readonly property var monthNames: [
"January","February","March","April","May","June",
"July","August","September","October","November","December"
]
readonly property int daysInMonth: new Date(viewYear, viewMonth + 1, 0).getDate()
// first weekday of month: Mon=0 … Sun=6
readonly property int startOffset: (new Date(viewYear, viewMonth, 1).getDay() + 6) % 7
readonly property int totalCells: Math.ceil((startOffset + daysInMonth) / 7) * 7
readonly property int todayYear: root.now.getFullYear()
readonly property int todayMonth: root.now.getMonth()
readonly property int todayDay: root.now.getDate()
implicitWidth: calBg.implicitWidth
implicitHeight: calBg.implicitHeight
Rectangle {
id: calBg
anchors.fill: parent
color: Theme.bg
border.color: Theme.border
border.width: Theme.borderWidth
radius: Theme.radius
implicitWidth: calLayout.implicitWidth + 24
implicitHeight: calLayout.implicitHeight + 24
Column {
id: calLayout
anchors { fill: parent; margins: 12 }
spacing: 6
// Month navigation
Row {
width: parent.width
spacing: 0
Text {
text: ""
color: Theme.text
font.pixelSize: 16
width: 20
horizontalAlignment: Text.AlignHCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (calPopup.viewMonth === 0) {
calPopup.viewMonth = 11
calPopup.viewYear--
} else {
calPopup.viewMonth--
}
}
}
}
Text {
text: calPopup.monthNames[calPopup.viewMonth] + " " + calPopup.viewYear
color: Theme.text
font.pixelSize: 13
font.bold: true
// fill remaining space between arrows
width: parent.width - 40
horizontalAlignment: Text.AlignHCenter
}
Text {
text: ""
color: Theme.text
font.pixelSize: 16
width: 20
horizontalAlignment: Text.AlignHCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (calPopup.viewMonth === 11) {
calPopup.viewMonth = 0
calPopup.viewYear++
} else {
calPopup.viewMonth++
}
}
}
}
}
// Weekday headers
Row {
spacing: 2
Repeater {
model: ["Mo","Tu","We","Th","Fr","Sa","Su"]
Text {
text: modelData
color: Theme.textDim
font.pixelSize: 10
width: 28
horizontalAlignment: Text.AlignHCenter
}
}
}
// Day grid
Grid {
columns: 7
spacing: 2
Repeater {
model: calPopup.totalCells
delegate: Item {
width: 28
height: 22
readonly property int dayNum: index - calPopup.startOffset + 1
readonly property bool valid:
index >= calPopup.startOffset &&
index < calPopup.startOffset + calPopup.daysInMonth
readonly property bool isToday:
valid &&
calPopup.viewYear === calPopup.todayYear &&
calPopup.viewMonth === calPopup.todayMonth &&
dayNum === calPopup.todayDay
Rectangle {
anchors.fill: parent
radius: 3
color: isToday ? Theme.accent : "transparent"
visible: isToday
}
Text {
anchors.centerIn: parent
text: parent.valid ? String(parent.dayNum) : ""
color: parent.isToday ? Theme.text : Theme.textDim
font.pixelSize: 11
font.bold: parent.isToday
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,39 @@
import Quickshell
import Quickshell.Services.Mpris
import QtQuick
import "../components"
Item {
id: root
signal clicked
// Pick the first playing player, fall back to first available
readonly property var player: {
const vals = Mpris.players.values
for (const p of vals) {
if (p.isPlaying) return p
}
return vals.length > 0 ? vals[0] : null
}
visible: player !== null
implicitWidth: player ? Math.min(240, titleLabel.implicitWidth + 20) : 0
implicitHeight: 24
Text {
id: titleLabel
anchors.centerIn: parent
width: parent.width - 20
text: root.player?.trackTitle ?? ""
color: Theme.text
font.pixelSize: 12
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
MouseArea {
anchors.fill: parent
onClicked: root.clicked()
}
}

View File

@ -0,0 +1,166 @@
import Quickshell
import Quickshell.Services.Mpris
import QtQuick
import QtQuick.Layouts
import "../components"
PopupWindow {
id: root
required property var player
implicitWidth: 280
implicitHeight: bg.implicitHeight
// Tickle positionChanged every frame while playing so the player.position
// binding re-evaluates. Paused while the user is dragging the seek bar.
FrameAnimation {
running: (root.player?.isPlaying ?? false) && !progressArea.pressed
onTriggered: root.player.positionChanged()
}
Rectangle {
id: bg
anchors.fill: parent
color: Theme.bg
border.color: Theme.border
border.width: Theme.borderWidth
radius: Theme.radius
implicitHeight: layout.implicitHeight + 28
ColumnLayout {
id: layout
anchors { fill: parent; margins: 14 }
spacing: 8
// Track info
Text {
text: root.player?.trackTitle ?? ""
color: Theme.text
font.pixelSize: 13
font.bold: true
Layout.fillWidth: true
elide: Text.ElideRight
}
Text {
text: root.player?.trackArtist ?? ""
color: Theme.textDim
font.pixelSize: 11
Layout.fillWidth: true
elide: Text.ElideRight
visible: (root.player?.trackArtist ?? "") !== ""
Layout.topMargin: -4
}
// Progress bar
Item {
visible: root.player?.positionSupported && root.player?.lengthSupported
&& (root.player?.length ?? 0) > 0
Layout.fillWidth: true
height: 12 // visual track is centred inside; extra height = easier to hit
// Track
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 3
radius: 2
color: Theme.progressTrack
// Filled portion
Rectangle {
width: root.player?.length > 0
? parent.width * (root.player.position / root.player.length)
: 0
height: parent.height
radius: parent.radius
color: Theme.accent
}
}
MouseArea {
id: progressArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
function seek(mx) {
if (!(root.player?.canSeek)) return
const ratio = Math.max(0, Math.min(1, mx / width))
root.player.position = ratio * (root.player?.length ?? 0)
}
onClicked: seek(mouseX)
onPositionChanged: if (pressed) seek(mouseX)
}
}
// Media controls
RowLayout {
Layout.fillWidth: true
spacing: 0
Item { Layout.fillWidth: true }
ControlButton {
icon: "⏮"
iconSize: 18
enabled: root.player?.canGoPrevious ?? false
onActivated: root.player.previous()
}
Item { width: 8 }
ControlButton {
icon: root.player?.isPlaying ? "⏸" : "▶"
iconSize: 22
enabled: true
onActivated: root.player?.togglePlaying()
}
Item { width: 8 }
ControlButton {
icon: "⏭"
iconSize: 18
enabled: root.player?.canGoNext ?? false
onActivated: root.player.next()
}
Item { Layout.fillWidth: true }
}
}
}
// Inline helper — a button that highlights on hover
component ControlButton: Rectangle {
id: btn
required property string icon
required property int iconSize
required property bool enabled
signal activated
implicitWidth: label.implicitWidth + 16
implicitHeight: label.implicitHeight + 10
radius: Theme.radius
color: btnArea.containsMouse ? Qt.rgba(1, 1, 1, 0.08) : 'transparent'
Behavior on color { ColorAnimation { duration: 80 } }
Text {
id: label
anchors.centerIn: parent
text: btn.icon
font.pixelSize: btn.iconSize
color: btn.enabled ? Theme.text : Theme.textDisabled
}
MouseArea {
id: btnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if (btn.enabled) btn.activated()
}
}
}

View File

@ -0,0 +1,78 @@
import Quickshell
import Quickshell.Io
import QtQuick
import "../components"
Item {
id: root
implicitWidth: row.implicitWidth + 10
implicitHeight: 24
property string connType: "none" // "wifi", "ethernet", "none"
property string connName: ""
// \uf1eb = FA wifi, \uf0e8 = FA sitemap (wired proxy), \uf127 = FA chain-broken
readonly property string netIcon:
connType === "wifi" ? "\uf1eb" :
connType === "ethernet" ? "\uf0e8" : "\uf127"
function parseLine(line) {
const idx1 = line.indexOf(":")
if (idx1 < 0) return
const idx2 = line.indexOf(":", idx1 + 1)
if (idx2 < 0) return
const type = line.substring(0, idx1)
const state = line.substring(idx1 + 1, idx2)
const conn = line.substring(idx2 + 1).trim()
if (state === "connected" && (type === "wifi" || type === "ethernet")) {
if (root.connType === "none" || type === "wifi") {
root.connType = type
root.connName = conn
}
}
}
Timer {
interval: 5000
running: true
repeat: true
triggeredOnStart: true
onTriggered: {
root.connType = "none"
root.connName = ""
netProc.running = true
}
}
Process {
id: netProc
command: ["nmcli", "-t", "-f", "TYPE,STATE,CONNECTION", "dev"]
stdout: SplitParser {
splitMarker: "\n"
onRead: data => root.parseLine(data)
}
onExited: running = false
}
Row {
id: row
anchors.centerIn: parent
spacing: 5
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.netIcon
font.family: "JetBrainsMono Nerd Font Mono"
font.pixelSize: 14
color: root.connType !== "none" ? Theme.text : Theme.textDim
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.connName.length > 16 ? root.connName.substring(0, 15) + "…" : root.connName
font.pixelSize: 11
color: Theme.textDim
visible: root.connName !== ""
}
}
}

View File

@ -0,0 +1,85 @@
import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import QtQuick
import "../components"
Item {
id: root
required property var barWindow
implicitWidth: trayRow.width
implicitHeight: 24
Row {
id: trayRow
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Repeater {
model: SystemTray.items
delegate: Item {
id: trayDelegate
required property var modelData
width: 24
height: 24
// Hover highlight
Rectangle {
anchors.fill: parent
radius: Theme.radius
color: hoverArea.containsMouse ? Qt.rgba(1,1,1,0.08) : "transparent"
Behavior on color { ColorAnimation { duration: 80 } }
}
IconImage {
id: iconImg
anchors.centerIn: parent
implicitSize: 16
visible: status === Image.Ready
source: {
const icon = trayDelegate.modelData.icon
if (!icon || icon === "") return ""
if (icon.startsWith("/") || icon.startsWith("file://") || icon.startsWith("image://")) return icon
const path = Quickshell.iconPath(icon, "")
if (path !== "") return "file://" + path
return "image://icon/" + icon
}
mipmap: true
}
// Letter fallback when icon fails to load
Text {
anchors.centerIn: parent
visible: iconImg.status !== Image.Ready
text: (trayDelegate.modelData.title ?? trayDelegate.modelData.id ?? "?").charAt(0).toUpperCase()
color: Theme.textDim
font.pixelSize: 11
font.bold: true
}
MouseArea {
id: hoverArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: mouse => {
const item = trayDelegate.modelData
const wantsMenu = mouse.button === Qt.RightButton || item.onlyMenu
if (wantsMenu && item.hasMenu) {
// display() needs the quickshell PanelWindow (not QQuickWindow).
// mapToItem(null) gives scene/window-local coordinates.
const pos = trayDelegate.mapToItem(null, 0, trayDelegate.height)
item.display(root.barWindow, Math.round(pos.x), Math.round(pos.y))
} else if (!item.onlyMenu) {
item.activate()
}
}
}
}
}
}
}

View File

@ -0,0 +1,222 @@
import Quickshell
import Quickshell.Services.Pipewire
import QtQuick
import QtQuick.Layouts
import "../components"
Item {
id: root
implicitWidth: chip.implicitWidth + 10
implicitHeight: 24
// Track all PipeWire nodes so their properties are populated.
// Without this, nodes in Pipewire.nodes are "unbound" and have null audio/type.
PwObjectTracker {
objects: Pipewire.nodes.values
}
// ── helpers ────────────────────────────────────────────────────────
function safeVolume(node) {
if (!node || !node.ready || !node.audio) return 0
const v = node.audio.volume
return (v !== undefined && !isNaN(v)) ? v : 0
}
function setVolume(node, v) {
if (!node || !node.ready || !node.audio) return
node.audio.volume = Math.max(0, Math.min(1, v))
}
function volIcon(vol, muted) {
if (muted) return "\uf6a9" // fa-volume-mute
if (vol > 0.6) return "\uf028" // fa-volume-up
if (vol > 0.2) return "\uf027" // fa-volume-down
return "\uf026" // fa-volume-off
}
// ── bar chip (default sink) ─────────────────────────────────────────
readonly property var defaultSink: Pipewire.defaultAudioSink
readonly property real defaultVolume: safeVolume(defaultSink)
readonly property bool defaultMuted: defaultSink?.audio?.muted ?? false
Row {
id: chip
anchors.centerIn: parent
spacing: 5
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.volIcon(root.defaultVolume, root.defaultMuted)
font.family: "JetBrainsMono Nerd Font Mono"
font.pixelSize: 14
color: root.defaultMuted ? Theme.textDim : Theme.text
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.defaultMuted ? "mute" : Math.round(root.defaultVolume * 100) + "%"
font.pixelSize: 11
color: Theme.textDim
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
if (root.defaultSink?.ready && root.defaultSink?.audio)
root.defaultSink.audio.muted = !root.defaultSink.audio.muted
} else {
mixerPopup.visible = !mixerPopup.visible
}
}
onWheel: wheel => {
if (!root.defaultSink?.ready) return
root.setVolume(root.defaultSink, root.defaultVolume + wheel.angleDelta.y / 120 * 0.05)
}
}
// ── mixer popup ─────────────────────────────────────────────────────
PopupWindow {
id: mixerPopup
visible: false
anchor.item: root
anchor.edges: Edges.Bottom
anchor.gravity: Edges.Bottom
implicitWidth: mixerBg.implicitWidth
implicitHeight: mixerBg.implicitHeight
Rectangle {
id: mixerBg
anchors.fill: parent
color: Theme.bg
border.color: Theme.border
border.width: Theme.borderWidth
radius: Theme.radius
implicitWidth: 300
implicitHeight: mixerCol.implicitHeight + 24
ColumnLayout {
id: mixerCol
anchors { fill: parent; margins: 12 }
spacing: 4
// ── Output devices ──────────────────────────────────────
Text {
text: "Output Devices"
color: Theme.textDim
font.pixelSize: 10
Layout.fillWidth: true
Layout.bottomMargin: 2
}
Repeater {
model: Pipewire.nodes
delegate: NodeRow {
required property var modelData
Layout.fillWidth: true
node: modelData
visible: modelData.isSink && !modelData.isStream && modelData.ready
}
}
// ── Application streams ────────────────────────────────
Text {
text: "Applications"
color: Theme.textDim
font.pixelSize: 10
Layout.fillWidth: true
Layout.topMargin: 6
Layout.bottomMargin: 2
}
Repeater {
model: Pipewire.nodes
delegate: NodeRow {
required property var modelData
Layout.fillWidth: true
node: modelData
// exclude monitor sources; include only audio output streams
visible: modelData.isStream && !modelData.isSink && modelData.ready
&& !(modelData.description ?? "").toLowerCase().includes("monitor")
}
}
}
}
}
// ── per-node row component ──────────────────────────────────────────
component NodeRow: RowLayout {
id: row
required property var node
spacing: 8
implicitHeight: 28
// mute toggle
Text {
id: nodeIcon
text: root.volIcon(root.safeVolume(row.node), row.node.audio?.muted ?? false)
font.family: "JetBrainsMono Nerd Font Mono"
font.pixelSize: 13
color: (row.node.audio?.muted ?? false) ? Theme.textDim : Theme.text
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (row.node.ready && row.node.audio)
row.node.audio.muted = !row.node.audio.muted
}
}
}
// name
Text {
text: row.node.description || row.node.nickname || row.node.name || "?"
font.pixelSize: 11
color: Theme.textDim
Layout.preferredWidth: 90
elide: Text.ElideRight
}
// horizontal slider
Item {
Layout.fillWidth: true
implicitHeight: 16
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 3
radius: 2
color: Theme.progressTrack
Rectangle {
width: parent.width * Math.min(1, root.safeVolume(row.node))
height: parent.height
radius: parent.radius
color: Theme.accent
}
}
MouseArea {
anchors { fill: parent; topMargin: -4; bottomMargin: -4 }
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
function seek(mx) {
root.setVolume(row.node, mx / width)
}
onClicked: seek(mouseX)
onPositionChanged: if (pressed) seek(mouseX)
}
}
// percentage
Text {
text: Math.round(root.safeVolume(row.node) * 100) + "%"
font.pixelSize: 10
color: Theme.textDim
Layout.preferredWidth: 30
horizontalAlignment: Text.AlignRight
}
}
}

View File

@ -0,0 +1,58 @@
import Quickshell
import Quickshell.Hyprland
import Quickshell.Widgets
import QtQuick
import "../components"
Rectangle {
id: root
required property var workspace
required property string screenName
visible: workspace?.lastIpcObject?.monitor === screenName
implicitWidth: Math.max(32, iconsRow.implicitWidth + 14)
implicitHeight: 18
radius: 6
color: workspace?.active ? Theme.accent : 'transparent'
Row {
id: iconsRow
anchors.centerIn: parent
spacing: 3
Repeater {
model: root.workspace?.toplevels
delegate: Item {
id: iconItem
required property var modelData
property string appClass: modelData.lastIpcObject["class"] ?? ""
property var entry: appClass !== "" ? DesktopEntries.heuristicLookup(appClass) : null
width: 16
height: 16
IconImage {
anchors.fill: parent
source: iconItem.entry && iconItem.entry.icon !== ""
? "image://icon/" + iconItem.entry.icon
: ""
}
}
}
// Workspace number shown when no windows are open
Text {
visible: (root.workspace?.toplevels?.values?.length ?? 0) === 0
text: root.workspace?.id ?? ""
color: root.workspace?.active ? Theme.text : Theme.textDim
font.pixelSize: 11
}
}
MouseArea {
anchors.fill: parent
onClicked: Hyprland.dispatch("workspace " + root.workspace.id)
}
}

View File

@ -0,0 +1,20 @@
import Quickshell.Hyprland
import QtQuick
Row {
id: root
required property string screenName
spacing: 4
Repeater {
id: workspacesRepeater
model: Hyprland.workspaces
property string screenName: root.screenName
delegate: WorkspaceButton {
required property var modelData
workspace: modelData
screenName: workspacesRepeater.screenName
}
}
}

View File

@ -0,0 +1,9 @@
Bar 1.0 Bar.qml
Clock 1.0 Clock.qml
MusicPlayer 1.0 MusicPlayer.qml
MusicPlayerControls 1.0 MusicPlayerControls.qml
NetworkStatus 1.0 NetworkStatus.qml
SysTray 1.0 SysTray.qml
VolumeControl 1.0 VolumeControl.qml
Workspaces 1.0 Workspaces.qml
WorkspaceButton 1.0 WorkspaceButton.qml

View File

@ -0,0 +1,11 @@
import QtQuick
import Quickshell.Widgets
import "."
WrapperRectangle {
radius: Theme.radius
color: 'transparent'
border.color: Theme.border
border.width: Theme.borderWidth
margin: Theme.enclosureMargin
}

View File

@ -0,0 +1,18 @@
pragma Singleton
import QtQuick
QtObject {
// Core palette
readonly property color bg: '#000000'
readonly property color accent: '#9B1A1A'
readonly property color border: '#FFFFFF'
readonly property color text: '#FFFFFF'
readonly property color textDim: '#888888'
readonly property color textDisabled: '#444444'
readonly property color progressTrack: '#333333'
// Shape / sizing
readonly property int radius: 4
readonly property int borderWidth: 2
readonly property int enclosureMargin: 3
}

View File

@ -0,0 +1,2 @@
singleton Theme 1.0 Theme.qml
Enclosure 1.0 Enclosure.qml

View File

@ -0,0 +1,13 @@
//@ pragma UseQApplication
import Quickshell
import "bar"
ShellRoot {
Variants {
model: Quickshell.screens
Bar {
// modelData injected by Variants; Bar.qml binds screen: modelData internally
}
}
}