add quickshell bar
This commit is contained in:
84
roles/quickshell/files/bar/Bar.qml
Normal file
84
roles/quickshell/files/bar/Bar.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
196
roles/quickshell/files/bar/Clock.qml
Normal file
196
roles/quickshell/files/bar/Clock.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
roles/quickshell/files/bar/MusicPlayer.qml
Normal file
39
roles/quickshell/files/bar/MusicPlayer.qml
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
166
roles/quickshell/files/bar/MusicPlayerControls.qml
Normal file
166
roles/quickshell/files/bar/MusicPlayerControls.qml
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
roles/quickshell/files/bar/NetworkStatus.qml
Normal file
78
roles/quickshell/files/bar/NetworkStatus.qml
Normal 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 !== ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
roles/quickshell/files/bar/SysTray.qml
Normal file
85
roles/quickshell/files/bar/SysTray.qml
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
roles/quickshell/files/bar/VolumeControl.qml
Normal file
222
roles/quickshell/files/bar/VolumeControl.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
roles/quickshell/files/bar/WorkspaceButton.qml
Normal file
58
roles/quickshell/files/bar/WorkspaceButton.qml
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
roles/quickshell/files/bar/Workspaces.qml
Normal file
20
roles/quickshell/files/bar/Workspaces.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
roles/quickshell/files/bar/qmldir
Normal file
9
roles/quickshell/files/bar/qmldir
Normal 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
|
||||||
11
roles/quickshell/files/components/Enclosure.qml
Normal file
11
roles/quickshell/files/components/Enclosure.qml
Normal 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
|
||||||
|
}
|
||||||
18
roles/quickshell/files/components/Theme.qml
Normal file
18
roles/quickshell/files/components/Theme.qml
Normal 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
|
||||||
|
}
|
||||||
2
roles/quickshell/files/components/qmldir
Normal file
2
roles/quickshell/files/components/qmldir
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
singleton Theme 1.0 Theme.qml
|
||||||
|
Enclosure 1.0 Enclosure.qml
|
||||||
13
roles/quickshell/files/shell.qml
Normal file
13
roles/quickshell/files/shell.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
roles/quickshell/tasks/main.yml
Normal file
42
roles/quickshell/tasks/main.yml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
- name: Check if quickshell path is a symlink
|
||||||
|
stat:
|
||||||
|
path: "{{ config_dir }}/quickshell"
|
||||||
|
register: quickshell_stat
|
||||||
|
|
||||||
|
- name: Remove existing quickshell symlink if present
|
||||||
|
file:
|
||||||
|
path: "{{ config_dir }}/quickshell"
|
||||||
|
state: absent
|
||||||
|
when: quickshell_stat.stat.exists and quickshell_stat.stat.islnk
|
||||||
|
|
||||||
|
- name: Create quickshell config directories
|
||||||
|
file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
loop:
|
||||||
|
- "{{ config_dir }}/quickshell"
|
||||||
|
- "{{ config_dir }}/quickshell/bar"
|
||||||
|
- "{{ config_dir }}/quickshell/components"
|
||||||
|
|
||||||
|
- name: Symlink quickshell config files
|
||||||
|
file:
|
||||||
|
src: "{{ role_path }}/files/{{ item.src }}"
|
||||||
|
dest: "{{ config_dir }}/quickshell/{{ item.dest }}"
|
||||||
|
state: link
|
||||||
|
force: true
|
||||||
|
loop:
|
||||||
|
# Root
|
||||||
|
- { src: shell.qml, dest: shell.qml }
|
||||||
|
# Shared components module
|
||||||
|
- { src: components/Theme.qml, dest: components/Theme.qml }
|
||||||
|
- { src: components/Enclosure.qml, dest: components/Enclosure.qml }
|
||||||
|
- { src: components/qmldir, dest: components/qmldir }
|
||||||
|
# Bar module (imported as "bar" in shell.qml)
|
||||||
|
- { src: bar/Bar.qml, dest: bar/Bar.qml }
|
||||||
|
- { src: bar/MusicPlayer.qml, dest: bar/MusicPlayer.qml }
|
||||||
|
- { src: bar/MusicPlayerControls.qml, dest: bar/MusicPlayerControls.qml }
|
||||||
|
- { src: bar/Workspaces.qml, dest: bar/Workspaces.qml }
|
||||||
|
- { src: bar/WorkspaceButton.qml, dest: bar/WorkspaceButton.qml }
|
||||||
|
- { src: bar/qmldir, dest: bar/qmldir }
|
||||||
Reference in New Issue
Block a user