adds overview, removes old stuff

This commit is contained in:
2026-08-19 20:47:29 +02:00
parent 6a9ae98c26
commit 67d50fb504
75 changed files with 3558 additions and 1691 deletions
@@ -0,0 +1,56 @@
// BatteryWidget.qml - battery icon + percentage
import QtQuick
import Quickshell.Io
import ".."
Pill {
id: root
property int capacity: 100
property string status: "Unknown"
property bool charging: status === "Charging"
property bool plugged: status === "Full" || status === "Not charging"
property bool critical: capacity <= 15 && !charging
property string icon: {
if (charging) return " "
if (plugged) return " "
if (capacity > 80) return ""
if (capacity > 60) return ""
if (capacity > 40) return ""
if (capacity > 20) return ""
return ""
}
Text {
text: root.icon + " " + root.capacity + "%"
font.family: Theme.fontSans
font.pixelSize: Theme.fontSize
color: root.critical ? Theme.color1 : Theme.pillText
}
Process {
id: batProc
running: false
command: ["bash", "-c",
"cat /sys/class/power_supply/BAT0/capacity 2>/dev/null; " +
"echo ---; " +
"cat /sys/class/power_supply/BAT0/status 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
const parts = this.text.split("---")
if (parts.length >= 2) {
const cap = parseInt(parts[0].trim())
if (!isNaN(cap)) root.capacity = cap
root.status = parts[1].trim()
}
}
}
onExited: batProc.running = false
}
Timer {
interval: 5000; running: true; repeat: true
triggeredOnStart: true
onTriggered: batProc.running = true
}
}
@@ -0,0 +1,61 @@
// modules/BluetoothWidget.qml - ᛒ status / device alias
// Uses bluetoothctl via a polled Process (no BlueZ QML bindings in QS yet).
import QtQuick
import Quickshell.Io
import ".."
Pill {
id: root
property string btStatus: "off" // "off" | "on" | "connected"
property string devAlias: ""
readonly property var bgColors: ({
"off": Qt.rgba(0.565, 0.545, 0.671, 0.3), // alpha(@color3, 0.3)
"on": Theme.color2,
"connected": Theme.color4,
})
// Override Pill's own color binding
color: bgColors[btStatus] ?? Theme.pill
onClicked: (m) => {
if (m.button === Qt.LeftButton)
Exec.run(["blueman-manager"])
}
Text {
text: {
var s = root.btStatus
if (s === "connected") return "ᛒ " + (root.devAlias || "connected")
if (s === "on") return "ᛒ on"
return "ᛒ off"
}
font { family: Theme.fontSans; pixelSize: Theme.fontSize }
color: Theme.pillText
}
// Poll bluetoothctl show + info every 5 s
Process {
id: btProc
running: false
command: ["bash", "-c",
"bluetoothctl show | grep -E 'Powered|Name'; " +
"bluetoothctl info 2>/dev/null | grep -E 'Name|Connected'"]
stdout: SplitParser {
onRead: (line) => {
if (/Powered:\s+no/i.test(line)) { root.btStatus = "off"; root.devAlias = "" }
if (/Powered:\s+yes/i.test(line)) { if (root.btStatus === "off") root.btStatus = "on" }
if (/Connected:\s+yes/i.test(line)) root.btStatus = "connected"
if (/Connected:\s+no/i.test(line)) { if (root.btStatus === "connected") { root.btStatus = "on"; root.devAlias = "" } }
var match = /^\s+Name:\s+(.+)/.exec(line)
if (match && root.btStatus === "connected") root.devAlias = match[1].trim()
}
}
onExited: btProc.running = false
}
Timer {
interval: 5000; running: true; repeat: true
triggeredOnStart: true
onTriggered: btProc.running = true
}
}
@@ -0,0 +1,93 @@
// CavaWidget.qml - audio visualiser via cava raw output
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import ".."
Rectangle {
id: root
property var bars: Array(12).fill(0)
property bool silence: bars.every(v => v === 0)
property bool allBlank: bars.every(v => v < 28.5)
readonly property var blocks: [" ","▁","▂","▃","▄","▅","▆","▇","█"]
visible: !allBlank
implicitWidth: cavaRow.implicitWidth + Theme.pillPadH * 2
implicitHeight: Theme.barHeight
radius: Theme.radius
color: cavaHover.containsMouse ? Theme.pill : Theme.pill
Behavior on color { ColorAnimation { duration: 150 } }
RowLayout {
id: cavaRow
anchors.centerIn: parent
spacing: 1
Repeater {
model: root.bars.length
Text {
required property int index
text: root.silence
? " "
: root.blocks[Math.min(Math.floor(root.bars[index] / 28.5), 8)]
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize + 1
color: Theme.pillText
}
}
}
MouseArea {
id: cavaHover
anchors.fill: parent
hoverEnabled: true
onClicked: Exec.run(["pavucontrol"])
}
// Write the cava config once at startup, then run cava pointing at it.
Component.onCompleted: writeCfg.running = true
Process {
id: writeCfg
running: false
// Plain double-quoted string - no JS interpolation, bash sees ${VAR} verbatim.
command: ["bash", "-c",
"mkdir -p /tmp/qs-cava && cat > /tmp/qs-cava/cava.ini <<'CFG'\n" +
"[general]\n" +
"framerate = 30\n" +
"bars = 12\n" +
"[input]\n" +
"method = pipewire\n" +
"source = auto\n" +
"[smoothing]\n" +
"noise_reduction = 77\n" +
"monstercat = 1\n" +
"[output]\n" +
"method = raw\n" +
"raw_target = /dev/stdout\n" +
"data_format = ascii\n" +
"ascii_max_range = 255\n" +
"bar_delimiter = 59\n" +
"CFG\n"
]
onExited: cavaProc.running = true
}
Process {
id: cavaProc
running: false
command: ["cava", "-p", "/tmp/qs-cava/cava.ini"]
stdout: SplitParser {
onRead: (line) => {
const parts = line.trim().replace(/;$/, "").split(";");
if (parts.length >= root.bars.length) {
root.bars = parts.slice(0, root.bars.length)
.map(v => parseInt(v) || 0);
}
}
}
onExited: cavaProc.running = true
}
}
@@ -0,0 +1,30 @@
// modules/ClockWidget.qml - " HH:MM DD Mon" (matches waybar clock format)
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import ".."
Pill {
onClicked: (m) => {
if (m.button === Qt.LeftButton)
Exec.run(["kitty", "-e", "calcure", "--class=float", "-T", "calcure"])
}
Text {
text: " " + Qt.formatDateTime(clock.now, "HH:mm") +
" " + Qt.formatDateTime(clock.now, "d MMM")
font { family: Theme.fontSans; pixelSize: Theme.fontSize }
color: Theme.pillText
}
// Update every 10 s (no need for per-second ticks)
QtObject {
id: clock
property var now: new Date()
property var timer: Timer {
interval: 10000; running: true; repeat: true
triggeredOnStart: true
onTriggered: clock.now = new Date()
}
}
}
@@ -0,0 +1,55 @@
// CpuWidget.qml - "X.XGHz | Y%"
import QtQuick
import Quickshell.Io
import ".."
Pill {
id: root
property real freqGhz: 0
property int usagePct: 0
property int prevIdle: 0
property int prevTotal: 0
onClicked: (m) => {
if (m.button === Qt.LeftButton) Exec.run(["kitty", "-e", "btop"])
}
Text {
text: root.freqGhz.toFixed(1) + "GHz | " + root.usagePct + "%"
font.family: Theme.fontSans
font.pixelSize: Theme.fontSize
color: Theme.pillText
}
// /proc/stat - first line is total CPU
Process {
id: statProc
running: false
command: ["bash", "-c", "head -1 /proc/stat && cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
const lines = this.text.split("\n")
// line 0: cpu user nice system idle iowait irq softirq steal
const nums = lines[0].replace(/^cpu\s+/, "").split(/\s+/).map(Number)
const idle = (nums[3] || 0) + (nums[4] || 0)
const total = nums.reduce((s, v) => s + v, 0)
const dIdle = idle - root.prevIdle
const dTotal = total - root.prevTotal
if (dTotal > 0) root.usagePct = Math.round((1 - dIdle / dTotal) * 100)
root.prevIdle = idle
root.prevTotal = total
// line 1: current frequency in kHz
const khz = parseInt(lines[1] || "0")
if (!isNaN(khz) && khz > 0) root.freqGhz = khz / 1e6
}
}
onExited: statProc.running = false
}
Timer {
interval: 1500; running: true; repeat: true
triggeredOnStart: true
onTriggered: statProc.running = true
}
}
@@ -0,0 +1,248 @@
// MediaCavaWidget.qml - cava visualiser + media info + volume in one pill
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Services.Mpris
import ".."
Rectangle {
id: root
// ── Cava ──────────────────────────────────────────────────
property var bars: Array(12).fill(0)
property bool silence: bars.every(v => v === 0)
property bool allBlank: bars.every(v => v < 28.5)
property bool silentMode: false
readonly property var blocks: [" ","▁","▂","▃","▄","▅","▆","▇","█"]
// ── Audio (wpctl - avoids PipeWire binding issues) ────────
property real vol: 0.0
property bool muted: false
property string volIcon: {
if (muted || vol === 0) return " "
if (vol < 0.34) return " "
if (vol < 0.67) return " "
return " "
}
// ── Media ─────────────────────────────────────────────────
property MprisPlayer activePlayer: {
var players = Mpris.players.values
for (var i = 0; i < players.length; i++)
if (players[i].identity.toLowerCase() === "spotify") return players[i]
return players.length > 0 ? players[0] : null
}
property string trackText: {
if (!activePlayer) return ""
var p = activePlayer
var info = ""
if (p.trackArtists && p.trackTitle)
info = Array.from(p.trackArtists).join(", ") + " - " + p.trackTitle
else if (p.trackTitle)
info = p.trackTitle
if (info.length > 45) info = info.substring(0, 45) + "..."
if (p.playbackState !== MprisPlaybackState.Playing && info)
info = " " + info
return info + " "
}
// ── Silence delay ─────────────────────────────────────────
onAllBlankChanged: {
if (allBlank) silenceTimer.start()
else { silenceTimer.stop(); silentMode = false }
}
Timer {
id: silenceTimer
interval: Theme.cavaDissapearTime
repeat: false
onTriggered: root.silentMode = true
}
// ── Hover → media popup ───────────────────────────────────
property bool hovered: mouseArea.containsMouse
onHoveredChanged: {
if (hovered) GlobalStates.popups.open("media", mapToItem(null, width / 2, 0).x)
}
// ── Appearance ────────────────────────────────────────────
implicitHeight: Theme.barHeight
implicitWidth: contentRow.implicitWidth + Theme.pillPadH * 2
radius: Theme.radius
clip: true
color: root.hovered ? Theme.pillHover : Theme.pill
Behavior on color { ColorAnimation { duration: 150 } }
// Volume fill - slightly more opaque than background, fills left→right by volume %
Rectangle {
anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
width: parent.width * Math.min(root.vol, 1.0)
radius: Theme.radius
color: root.hovered ? Theme.setColorAlpha(Theme.pill, 0.3) : Theme.setColorAlpha(Theme.pill, 0.9)
Behavior on width { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
}
// ── Content ───────────────────────────────────────────────
RowLayout {
id: contentRow
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Theme.pillPadH
spacing: 4
// Cava bars -d slides right and collapses after silence delay
Item {
id: cavaContainer
clip: true
implicitHeight: Theme.barHeight
implicitWidth: root.silentMode ? 0 : cavaRow.implicitWidth
Behavior on implicitWidth {
NumberAnimation { duration: 350; easing.type: Easing.InOutCubic }
}
FontMetrics {
id: barMetrics
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize + 1
}
Row {
id: cavaRow
anchors.verticalCenter: parent.verticalCenter
spacing: 1
x: root.silentMode ? 16 : 0
Behavior on x {
NumberAnimation { duration: 350; easing.type: Easing.InOutCubic }
}
Repeater {
model: root.bars.length
Text {
required property int index
width: barMetrics.advanceWidth("█")
horizontalAlignment: Text.AlignHCenter
text: root.silence
? " "
: root.blocks[Math.min(Math.floor(root.bars[index] / 28.5), 8)]
font { family: Theme.fontMono; pixelSize: Theme.fontSize + 1 }
color: Theme.pillText
}
}
}
}
// Track info
Text {
visible: root.trackText !== ""
text: root.trackText
font { family: Theme.fontSans; pixelSize: Theme.fontSize }
color: Theme.pillText
elide: Text.ElideRight
maximumLineCount: 1
}
// Volume icon
Text {
text: root.volIcon
font { family: Theme.fontMono; pixelSize: Theme.fontSize }
color: "#fab387"
}
// Volume percentage
Text {
text: root.muted ? "muted" : Math.round(root.vol * 100) + "%"
font { family: Theme.fontSans; pixelSize: Theme.fontSize }
color: Theme.pillText
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (m) => {
if (m.button === Qt.RightButton) {
Exec.run(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"])
root.muted = !root.muted
}
}
onWheel: (w) => {
var step = w.angleDelta.y > 0 ? "4%+" : "4%-"
Exec.run(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", step])
// Optimistic update - poll syncs it within 1s
root.vol = Math.max(0, Math.min(1.5, root.vol + (w.angleDelta.y > 0 ? 0.04 : -0.04)))
}
}
// ── Volume poll (wpctl) ───────────────────────────────────
Process {
id: volProc
running: false
command: ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"]
stdout: SplitParser {
onRead: (line) => {
// "Volume: 0.40" or "Volume: 0.40 [MUTED]"
var m = /Volume:\s+([\d.]+)(.*)/.exec(line)
if (m) {
root.vol = parseFloat(m[1])
root.muted = m[2].includes("MUTED")
}
}
}
onExited: volProc.running = false
}
Timer {
interval: 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: volProc.running = true
}
// ── Cava process ──────────────────────────────────────────
Component.onCompleted: writeCfg.running = true
Process {
id: writeCfg
running: false
command: ["bash", "-c",
"mkdir -p /tmp/qs-cava && cat > /tmp/qs-cava/cava.ini <<'CFG'\n" +
"[general]\n" +
"framerate = 30\n" +
"bars = 12\n" +
"[input]\n" +
"method = pipewire\n" +
"source = auto\n" +
"[smoothing]\n" +
"noise_reduction = 77\n" +
"monstercat = 1\n" +
"[output]\n" +
"method = raw\n" +
"raw_target = /dev/stdout\n" +
"data_format = ascii\n" +
"ascii_max_range = 255\n" +
"bar_delimiter = 59\n" +
"CFG\n"
]
onExited: cavaProc.running = true
}
Process {
id: cavaProc
running: false
command: ["cava", "-p", "/tmp/qs-cava/cava.ini"]
stdout: SplitParser {
onRead: (line) => {
const parts = line.trim().replace(/;$/, "").split(";")
if (parts.length >= root.bars.length)
root.bars = parts.slice(0, root.bars.length).map(v => parseInt(v) || 0)
}
}
onExited: cavaProc.running = true
}
}
@@ -0,0 +1,207 @@
// MediaCavaWidget.qml - cava visualiser + media info + volume in one pill
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Services.Mpris
import ".."
Popup {
id: root
popupName: "media"
property MprisPlayer player: {
var players = Mpris.players.values
for (var i = 0; i < players.length; i++)
if (players[i].identity.toLowerCase() === "spotify") return players[i]
return players.length > 0 ? players[0] : null
}
property bool isPlaying: player?.playbackState === MprisPlaybackState.Playing ?? false
property real displayPosition: 0
Timer {
interval: 1000
running: root.visible && root.player !== null
repeat: true
triggeredOnStart: true
onTriggered: root.displayPosition = root.player?.position ?? 0
}
function formatTime(secs) {
secs = Math.max(0, Math.floor(secs))
return Math.floor(secs / 60) + ":" + String(secs % 60).padStart(2, "0")
}
component CtrlBtn: Text {
property string icon: ""
property color baseColor: Theme.pillText
property color hoverColor: Theme.pillHover
signal activate()
text: icon
font { family: Theme.fontMono; pixelSize: 20 }
color: ma.containsMouse ? hoverColor : baseColor
Behavior on color { ColorAnimation { duration: 100 } }
MouseArea {
id: ma
anchors.fill: parent
hoverEnabled: true
onClicked: parent.activate()
}
}
ColumnLayout {
width: 200
spacing: 8
// Album art - hidden when no art is available
Rectangle {
visible: artImage.status === Image.Ready
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: 180
Layout.preferredHeight: artImage.status === Image.Ready ? 180 : 0
Layout.topMargin: artImage.status === Image.Ready ? 10 : 0
radius: Theme.radius
clip: true
color: Theme.pill
Image {
id: artImage
anchors.fill: parent
source: root.player?.trackArtUrl ?? ""
fillMode: Image.PreserveAspectCrop
smooth: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.player)
Exec.run(["hyprctl", "dispatch", "hl.dsp.focus({ window = 'class:" + root.player.identity + "' })"])
GlobalStates.popups.close()
}
}
}
// Track title with marquee scroll
Item {
id: titleClip
Layout.fillWidth: true
Layout.leftMargin: 15
Layout.rightMargin: 15
Layout.topMargin: 4
implicitHeight: titleText.implicitHeight
clip: true
Text {
id: titleText
text: root.player?.trackTitle ?? "Nothing playing"
font { family: Theme.fontSans; pixelSize: Theme.fontSize + 1; bold: true }
color: titleMa.containsMouse && root.player ? Qt.lighter(Theme.pillText, 1.3) : Theme.pillText
Behavior on color { ColorAnimation { duration: 100 } }
MouseArea {
id: titleMa
anchors.fill: parent
hoverEnabled: true
cursorShape: root.player ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.player) {
Exec.run(["hyprctl", "dispatch", "hl.dsp.focus({ window = 'class:" + root.player.identity + "' })"])
GlobalStates.popups.close()
}
}
onTextChanged: {
marquee.stop()
x = 0
Qt.callLater(() => {
if (implicitWidth > titleClip.width) marquee.start()
})
}
}
Component.onCompleted: Qt.callLater(() => {
if (titleText.implicitWidth > width) marquee.start()
})
SequentialAnimation {
id: marquee
loops: Animation.Infinite
PauseAnimation { duration: 1500 }
NumberAnimation {
target: titleText; property: "x"
from: 0
to: -(titleText.implicitWidth - titleClip.width + 10)
duration: Math.max(1, titleText.implicitWidth - titleClip.width) * 18
easing.type: Easing.Linear
}
PauseAnimation { duration: 800 }
NumberAnimation {
target: titleText; property: "x"
to: 0; duration: 400; easing.type: Easing.OutCubic
}
}
}
// Artist
Text {
Layout.fillWidth: true
Layout.leftMargin: 15
Layout.rightMargin: 15
horizontalAlignment: Text.AlignHCenter
text: root.player?.trackArtists ? Array.from(root.player.trackArtists).join(", ") : ""
font { family: Theme.fontSans; pixelSize: Theme.fontSize }
color: Theme.pillText
elide: Text.ElideRight
}
// Progress bar
Rectangle {
Layout.fillWidth: true
Layout.leftMargin: 15
Layout.rightMargin: 15
height: 3
radius: 2
color: Theme.pill
Rectangle {
width: root.player && root.player.length > 0
? Math.min(1, root.displayPosition / root.player.length) * parent.width
: 0
height: parent.height
radius: parent.radius
color: Theme.color6
}
}
// Time labels
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 15
Layout.rightMargin: 15
Text {
text: root.formatTime(root.displayPosition)
font { family: Theme.fontMono; pixelSize: Theme.fontSize - 1 }
color: Theme.pillText
}
Item { Layout.fillWidth: true }
Text {
text: root.formatTime(root.player?.length ?? 0)
font { family: Theme.fontMono; pixelSize: Theme.fontSize - 1 }
color: Theme.pillText
}
}
// Controls
RowLayout {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 10
spacing: 20
CtrlBtn { text: ""; onActivate: root.player?.previous() }
CtrlBtn { text: root.isPlaying ? "" : ""; onActivate: root.player?.togglePlaying() }
CtrlBtn { text: ""; onActivate: root.player?.stop() }
CtrlBtn { text: ""; onActivate: root.player?.next() }
}
}
}
@@ -0,0 +1,58 @@
// modules/MediaWidget.qml
// Mirrors waybar custom/spotify - uses MPRIS via Quickshell.Services.Mpris.
// Shows: artist - title + spotify icon. Click to play/pause, scroll to skip.
import QtQuick
import Quickshell.Services.Mpris
import ".."
Pill {
id: root
// Pick the first active player (prefer spotify)
property MprisPlayer activePlayer: {
var players = Mpris.players.values
for (var i = 0; i < players.length; i++)
if (players[i].identity.toLowerCase() === "spotify") return players[i]
return players.length > 0 ? players[0] : null
}
property string trackText: {
if (!activePlayer) return ""
var p = activePlayer
var info = ""
if (p.trackArtists && p.trackTitle)
info = Array.from(p.trackArtists).join(", ") + " - " + p.trackTitle
else if (p.trackTitle)
info = p.trackTitle
if (info.length > 45) info = info.substring(0, 45) + "..."
if (p.playbackState !== MprisPlaybackState.Playing && info)
info = " " + info
return info + " " // trailing Nerd Font Spotify icon
}
visible: trackText !== ""
Text {
text: root.trackText
font { family: Theme.fontSans; pixelSize: Theme.fontSize }
color: Theme.pillText
elide: Text.ElideRight
maximumLineCount: 1
}
onClicked: (m) => {
if (!root.activePlayer) return
if (m.button === Qt.LeftButton)
root.activePlayer.togglePlaying()
}
onScrolled: (w) => {
if (!root.activePlayer) return
if (w.angleDelta.y > 0) root.activePlayer.next()
else root.activePlayer.previous()
}
onHoveredChanged: {
if (hovered) GlobalStates.popups.open("media", mapToItem(null, width / 2, 0).x)
}
}
@@ -0,0 +1,43 @@
// MemoryWidget.qml - " X.XX / Y GB"
import QtQuick
import Quickshell.Io
import ".."
Pill {
id: root
property real usedGb: 0
property real totalGb: 0
onClicked: (m) => {
if (m.button === Qt.LeftButton) Exec.run(["kitty", "-e", "btop"])
}
Text {
text: " " + root.usedGb.toFixed(2) + " / " + root.totalGb.toFixed(0) + " GB"
font.family: Theme.fontSans
font.pixelSize: Theme.fontSize
color: Theme.pillText
}
Process {
id: memProc
running: false
command: ["cat", "/proc/meminfo"]
stdout: StdioCollector {
onStreamFinished: {
const text = this.text
const total = parseInt((/MemTotal:\s+(\d+)/.exec(text) || [])[1] || "0")
const avail = parseInt((/MemAvailable:\s+(\d+)/.exec(text) || [])[1] || "0")
root.totalGb = total / 1048576
root.usedGb = (total - avail) / 1048576
}
}
onExited: memProc.running = false
}
Timer {
interval: 5000; running: true; repeat: true
triggeredOnStart: true
onTriggered: memProc.running = true
}
}
@@ -0,0 +1,20 @@
// modules/PowerMenuWidget.qml - ⏻ button with inline popup menu.
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import ".."
Pill {
id: root
onHoveredChanged: {
if (hovered) GlobalStates.popups.toggle("powerMenu", mapToItem(null, width / 2, 0).x)
}
onClicked: {
GlobalStates.popups.toggle("powerMenu", mapToItem(null, width / 2, 0).x)
}
Text {
text: " "
font { family: Theme.fontMono; pixelSize: Theme.fontSize }
color: Theme.pillText
}
}
@@ -0,0 +1,59 @@
// PowerProfilesWidget.qml - ⚡/⚖/🔋 + click-to-cycle
import QtQuick
import Quickshell.Io
import ".."
Pill {
id: root
property string profile: "balanced"
readonly property var profileOrder: ["performance", "balanced", "power-saver"]
readonly property var icons: ({
"performance": "⚡",
"balanced": "⚖",
"power-saver": "🔋",
})
onClicked: (m) => {
if (m.button !== Qt.LeftButton) return;
const i = profileOrder.indexOf(root.profile);
const next = profileOrder[(i + 1) % profileOrder.length];
setProc.command = ["powerprofilesctl", "set", next];
setProc.running = true;
root.profile = next; // optimistic update
}
Text {
text: (root.icons[root.profile] ?? "⚡")
font.family: Theme.fontSans
font.pixelSize: Theme.fontSize
color: Theme.pillText
}
// Read current profile periodically
Process {
id: readProc
running: false
command: ["powerprofilesctl", "get"]
stdout: SplitParser {
onRead: (line) => root.profile = line.trim()
}
onExited: readProc.running = false
}
// Setter - command is rewritten on each click
Process {
id: setProc
running: false
command: ["true"]
onExited: setProc.running = false
}
Timer {
interval: 2000
running: true
repeat: true
triggeredOnStart: true
onTriggered: readProc.running = true
}
}
@@ -0,0 +1,78 @@
// modules/SysTrayWidget.qml - SNI system tray (nm-applet, blueman ...)
import QtQuick
import QtQuick.Layouts
import Quickshell.Services.SystemTray
import Quickshell
import ".."
Rectangle {
id: root
required property var parentWindow
color: "transparent"
radius: Theme.radius
implicitWidth: trayRow.implicitWidth + 6
implicitHeight: Theme.barHeight
RowLayout {
id: trayRow
anchors.centerIn: parent
spacing: Theme.spacing
Repeater {
model: SystemTray.items
Rectangle {
id: trayRect
required property SystemTrayItem modelData
width: Theme.barHeight-Theme.barPadding*2; height: Theme.barHeight-Theme.barPadding*2
radius: Theme.radius
color: trayHover.containsMouse
? Theme.pillHover
: Theme.pill
Behavior on color { ColorAnimation { duration: 150 } }
Image {
anchors { fill: parent; margins: 4 }
source: modelData.icon
fillMode: Image.PreserveAspectFit
smooth: true
}
QsMenuAnchor {
id: menuAnchor
menu: modelData.menu
anchor.window: root.parentWindow
anchor.rect: Qt.rect(
trayRect.mapToItem(null, 0, 0).x,
trayRect.mapToItem(null, 0, 0).y,
trayRect.width,
trayRect.height
)
}
MouseArea {
id: trayHover
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (m) => {
if (m.button === Qt.LeftButton)
modelData.activate()
else if (modelData.hasMenu)
menuAnchor.open()
}
}
// Attention indicator dot
Rectangle {
visible: modelData.status === SystemTrayItem.NeedsAttention
width: 5; height: 5; radius: 2.5
color: Theme.wsUrgent
anchors { bottom: parent.bottom; right: parent.right; margins: 1 }
}
}
}
}
}
@@ -0,0 +1,43 @@
// TemperatureWidget.qml - CPU package temperature
import QtQuick
import Quickshell.Io
import ".."
Pill {
id: root
property int tempC: 0
property bool critical: tempC >= 80
property string icon: tempC < 50 ? "" : tempC < 70 ? "" : ""
onClicked: (m) => {
if (m.button === Qt.LeftButton) Exec.run(["xsensors"])
}
Text {
text: root.icon + " " + root.tempC + "°C"
font.family: Theme.fontSans
font.pixelSize: Theme.fontSize
color: root.critical ? Theme.color1 : Theme.pillText
}
// Read first available CPU package sensor - works regardless of hwmon number
Process {
id: tempProc
running: false
command: ["bash", "-c", "cat /sys/class/hwmon/hwmon*/temp1_input 2>/dev/null | head -1"]
stdout: SplitParser {
onRead: (line) => {
const raw = parseInt(line.trim())
if (!isNaN(raw)) root.tempC = Math.round(raw / 1000)
}
}
onExited: tempProc.running = false
}
Timer {
interval: 4000; running: true; repeat: true
triggeredOnStart: true
onTriggered: tempProc.running = true
}
}
@@ -0,0 +1,197 @@
// WallpaperPopup.qml - A popup for browsing and setting wallpapers from /usr/share/wallpapers
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import ".."
PanelWindow {
id: root
required property var screen
property bool logicalOpen: GlobalStates.popups.active === "wallpaper"
property bool _keepVisible: false
visible: logicalOpen || _keepVisible
onLogicalOpenChanged: {
if (logicalOpen) {
closeTimer.stop()
_keepVisible = false
if (wallpaperModel.count === 0) scanProc.running = true
openY.start()
openOpacity.start()
focusTimer.start()
} else {
_keepVisible = true
closeY.start()
closeOpacity.start()
closeTimer.restart()
}
}
Timer { id: closeTimer; interval: 280; onTriggered: root._keepVisible = false }
Timer { id: focusTimer; interval: 50; onTriggered: grid.forceActiveFocus() }
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "quickshell-popup-wallpaper"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
anchors { top: true; left: true; right: true; bottom: true }
color: Qt.rgba(0, 0, 0, 0)
MouseArea { anchors.fill: parent; onClicked: GlobalStates.popups.close() }
NumberAnimation { id: openY; target: box; property: "y"; to: 1; duration: 250; easing.type: Easing.OutCubic }
NumberAnimation { id: closeY; target: box; property: "y"; to: -340; duration: 200; easing.type: Easing.InCubic }
NumberAnimation { id: openOpacity; target: box; property: "opacity"; to: Theme.popupOpacity; duration: 200 }
NumberAnimation { id: closeOpacity; target: box; property: "opacity"; to: 0.0; duration: 200 }
// ── Named-pipe IPC ────────────────────────────────────────
// Trigger from Hyprland keybind: echo 1 > /tmp/qs-wallpaper-ipc
// Trigger from another QML popup: Exec.run(["bash", "-c", "echo 1 > /tmp/qs-wallpaper-ipc"])
Process {
id: ipcReader
running: true
command: ["bash", "-c",
"rm -f /tmp/qs-wallpaper-ipc && " +
"mkfifo /tmp/qs-wallpaper-ipc && " +
"while true; do cat /tmp/qs-wallpaper-ipc; done"]
stdout: SplitParser {
onRead: () => GlobalStates.popups.toggle("wallpaper", 0)
}
onExited: running = true
}
// ── Wallpaper scanner ─────────────────────────────────────
ListModel { id: wallpaperModel }
Process {
id: scanProc
running: false
command: ["bash", "-c",
"find /usr/share/wallpapers -type f " +
"\\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\) " +
"| sort"]
stdout: SplitParser {
onRead: (line) => {
var p = line.trim()
if (p) wallpaperModel.append({ path: p })
}
}
}
Process { id: setProc; running: false }
// ── Popup box ─────────────────────────────────────────────
Rectangle {
id: box
// anchorX = 0 → centered; anchorX > 0 → centered under widget
x: {
var ax = GlobalStates.popups.anchorX
var cx = ax > 0 ? ax - width / 2 : parent.width / 2 - width / 2
return Math.max(4, Math.min(parent.width - width - 4, cx))
}
y: -340
opacity: 0.0
width: 324
height: 500
color: Theme.popupBackground
radius: Theme.radius * 2
clip: true
border.color: Theme.popupBorderColor
border.width: Theme.popupBorderWidth
MouseArea { anchors.fill: parent }
Text {
anchors.centerIn: parent
visible: wallpaperModel.count === 0
text: "Scanning..."
font { family: Theme.fontSans; pixelSize: Theme.fontSize }
color: Theme.pillText
opacity: 0.5
}
GridView {
id: grid
anchors { fill: parent; margins: 8 }
clip: true
cellWidth: Math.floor(width / 2)
cellHeight: 104
focus: true
keyNavigationEnabled: true
currentIndex: 0
Keys.onReturnPressed: {
var item = wallpaperModel.get(grid.currentIndex)
if (item) {
setProc.command = ["waypaper", "--wallpaper", item.path]
setProc.running = true
GlobalStates.popups.close()
}
}
Keys.onEscapePressed: GlobalStates.popups.close()
model: wallpaperModel
highlight: Rectangle {
z: 2
radius: Theme.radius + 1
color: "transparent"
border.color: Theme.accent ?? "#cba6f7"
border.width: 2
}
highlightFollowsCurrentItem: true
highlightMoveDuration: 80
delegate: Item {
required property string path
required property int index
width: grid.cellWidth - 4
height: 100
Rectangle {
anchors { fill: parent; margins: 2 }
radius: Theme.radius
color: "black"
clip: true
Image {
anchors.fill: parent
source: "file://" + path
fillMode: Image.PreserveAspectCrop
sourceSize: Qt.size(152, 96)
smooth: true
asynchronous: true
opacity: status === Image.Ready ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
Rectangle {
anchors.fill: parent
radius: Theme.radius
color: hover.containsMouse ? Qt.rgba(1, 1, 1, 0.2) : "transparent"
Behavior on color { ColorAnimation { duration: 100 } }
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
grid.currentIndex = index
setProc.command = ["waypaper", "--wallpaper", path]
setProc.running = true
GlobalStates.popups.close()
}
onEntered: grid.currentIndex = index
}
}
}
}
}
}
@@ -0,0 +1,14 @@
// WallpaperWidget.qml - pill that opens the wallpaper changer popup
import QtQuick
import ".."
Pill {
id: root
onClicked: GlobalStates.popups.toggle("wallpaper", mapToItem(null, width / 2, 0).x)
Text {
text: " "
font { family: Theme.fontMono; pixelSize: Theme.fontSize }
color: Theme.pillText
}
}
@@ -0,0 +1,34 @@
// modules/WeatherWidget.qml - wttr.in one-liner, refreshed hourly
import QtQuick
import Quickshell.Io
import ".."
Pill {
id: root
property string weatherText: "..."
Text {
text: weatherText
font { family: Theme.fontSans; pixelSize: Theme.fontSize }
color: Theme.pillText
}
// ── Fetch via curl ────────────────────────────────────────
Process {
id: curl
command: ["curl", "-s", "--max-time", "8", "https://wttr.in/?format=1"]
running: false
stdout: SplitParser {
onRead: (line) => root.weatherText = line.trim()
}
onExited: curl.running = false
}
Timer {
interval: 3600000 // 1 hour
running: true
repeat: true
triggeredOnStart: true
onTriggered: curl.running = true
}
}
@@ -0,0 +1,30 @@
// modules/WindowTitleWidget.qml - hyprland/window equivalent
import QtQuick
import Quickshell.Hyprland
import ".."
Pill {
id: root
required property var screen
clip: true
Behavior on implicitWidth {
NumberAnimation { duration: 250; easing.type: Easing.InOutCubic }
}
readonly property string displayTitle: {
let rawTitle = Hyprland.activeToplevel ? Hyprland.activeToplevel.title : "";
if (rawTitle.length > 45) {
return rawTitle.substring(0, Theme.titleLength) + "...";
}
return rawTitle;
}
Text {
text: root.displayTitle
font { family: Theme.fontSans; pixelSize: Theme.fontSize }
color: Theme.pillText
elide: Text.ElideRight
maximumLineCount: 1
}
}
@@ -0,0 +1,107 @@
// WorkspacesWidget.qml - shows workspaces and allows switching between them
import QtQuick
import QtQuick.Layouts
import Quickshell.Hyprland
import ".."
Rectangle {
id: root
required property var screen
property var sortedWorkspaces: Array.from(Hyprland.workspaces.values).sort((a, b) => a.id - b.id)
property int activeIndex: {
var fid = Hyprland.focusedWorkspace?.id ?? -1
for (var i = 0; i < sortedWorkspaces.length; i++)
if (sortedWorkspaces[i].id === fid) return i
return -1
}
// Get the active workspace for this monitor
property var monitorActiveWorkspace: {
var activeWs = Hyprland.focusedWorkspace
// Check if focused workspace is on this monitor
if (activeWs && activeWs.monitor?.id === root.screen.id) {
return activeWs
}
// Otherwise find the first workspace on this monitor
for (var i = 0; i < sortedWorkspaces.length; i++) {
if (sortedWorkspaces[i].monitor?.id === root.screen.id) {
return sortedWorkspaces[i]
}
}
return null
}
function isWorkspaceOnThisMonitor(workspace) {
// Find the monitor in Hyprland.monitors that matches this screen's name
for (const monitor of Hyprland.monitors.values) {
if (monitor.name === root.screen.name && monitor.id === workspace.monitor.id) {
return true
}
}
return false
}
readonly property int btnWidth: 32
color: Theme.pill
radius: Theme.radius
clip: true
implicitWidth: wsRow.implicitWidth
implicitHeight: Theme.barHeight
Behavior on implicitWidth {
NumberAnimation { duration: Theme.workspaceSlideTime; easing.type: Easing.InOutCubic }
}
// Sliding active indicator - single Rectangle that travels between buttons
Rectangle {
visible: root.activeIndex >= 0
x: root.activeIndex * root.btnWidth
width: root.btnWidth
height: parent.height
color: Theme.wsActive
Behavior on x {
NumberAnimation { duration: Theme.workspaceSlideTime; easing.type: Easing.InOutCubic }
}
}
RowLayout {
id: wsRow
anchors.fill: parent
spacing: 0
Repeater {
model: root.sortedWorkspaces
delegate: Rectangle {
required property var modelData
Layout.fillHeight: true
implicitWidth: root.btnWidth
color: wsBtn.containsMouse ? Theme.pillHover : "transparent"
// Reduce opacity for workspaces not on this monitor
opacity: root.isWorkspaceOnThisMonitor(modelData) ? 1.0 : 0.4
Behavior on opacity { NumberAnimation { duration: 150 } }
Behavior on color { ColorAnimation { duration: 150 } }
Text {
anchors.centerIn: parent
text: modelData.id
font { family: Theme.fontSans; pixelSize: Theme.fontSizeSecondary }
color: Theme.pillText
}
MouseArea {
id: wsBtn
anchors.fill: parent
hoverEnabled: true
onClicked: Hyprland.dispatch("hl.dsp.focus({ workspace = '" + modelData.id + "' })")
onWheel: (w) => Hyprland.dispatch(
"hl.dsp.focus({ workspace = \"" + (w.angleDelta.y > 0 ? "e+1" : "e-1") + "\" })"
)
}
}
}
}
}
@@ -0,0 +1,53 @@
# Overview module - credits
The files in this directory (`Overview.qml`, `OverviewWidget.qml`, `OverviewWindow.qml`) and
their support singletons (`../../common/Appearance.qml`, `../../common/Config.qml`,
`../../common/functions/ColorUtils.qml`, `../../common/widgets/Styled*.qml`,
`../../services/HyprlandData.qml`) are copied from
[Shanu-Kumawat/quickshell-overview](https://github.com/Shanu-Kumawat/quickshell-overview),
which was itself extracted from the overview feature in
[end-4/dots-hyprland](https://github.com/end-4/dots-hyprland) ("illogical-impulse") by
[end-4](https://github.com/end-4).
Released under the GPL by the upstream project.
### Local deviations from upstream
- `Overview.qml` / `OverviewWidget.qml` / `OverviewWindow.qml`: added one `import "../.."` line
each, so `GlobalStates.overviewOpen` (merged into `own/GlobalStates.qml`, see below) resolves
from this subdirectory. No other logic changed.
- `../../common/Appearance.qml`: `defaultColors` was retuned to pull from this shell's own
`Theme`/`Colors` wallust singletons instead of the stock M3 purple palette, so the overview
matches the bar's colors (needed one added `import ".."`). This is the module's own documented
way to customize its palette (see upstream README, "Theme & Colors").
- `../../common/Config.qml`: untouched. Transparency/rounding are instead tuned via
`~/.config/quickshell/overview/config.json` (the module's own documented user-override file),
kept separate from the upstream defaults it reads.
## Why a copy instead of a symlink/submodule
The module was merged into this shell's own process (`own/shell.qml`) so it runs in the same
`quickshell -c own` instance as the bar, rather than as a second `qs -c overview` process.
An untouched clone of the upstream repo is kept side by side at
`~/.config/quickshell/overview` - use it as the reference copy when checking for upstream
updates (`git -C ~/.config/quickshell/overview pull`) and re-sync the files above by hand if
anything changes there.
## Configuration
Runtime options (grid size, scale, effects, etc.) are unaffected by the merge - `Config.qml`
still reads user overrides from `$XDG_CONFIG_HOME/quickshell/overview/config.json` regardless
of where the QML itself lives. See `~/.config/quickshell/overview/config.example.json` and
`~/.config/quickshell/overview/README.md` (in the untouched upstream clone) for the full list
of options.
## Toggling it
The merged module still registers the same IPC target, just under the `own` shell instance:
```bash
qs ipc -c own call overview toggle
qs ipc -c own call overview open
qs ipc -c own call overview close
```
@@ -0,0 +1,253 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
import "../../common"
import "../../services"
import "../.."
import "."
Scope {
id: overviewScope
Variants {
id: overviewVariants
model: Quickshell.screens
PanelWindow {
id: root
required property var modelData
readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen)
property bool monitorIsFocused: (Hyprland.focusedMonitor?.id == monitor?.id)
property bool blurEnabled: Config.options.overview.effects.enableBlur
property bool backdropEnabled: Config.options.overview.effects.enableBackdrop
property real backdropOpacity: Math.max(0, Math.min(1, Config.options.overview.effects.backdropOpacity))
property bool closeOnFocusLoss: Config.options.overview.closeOnFocusLoss ?? true
screen: modelData
visible: GlobalStates.overviewOpen
WlrLayershell.namespace: blurEnabled ? "quickshell:overview-blur" : "quickshell:overview"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
color: "transparent"
anchors {
top: true
bottom: true
left: true
right: true
}
HyprlandFocusGrab {
id: grab
windows: [root]
property bool canBeActive: root.monitorIsFocused
active: false
onCleared: () => {
// Only the monitor that owns the grab may close the overview
if (root.closeOnFocusLoss && !active && canBeActive)
GlobalStates.overviewOpen = false;
}
}
Connections {
target: GlobalStates
function onOverviewOpenChanged() {
if (GlobalStates.overviewOpen) {
delayedGrabTimer.start();
}
}
}
// Re-evaluate grab ownership when focused monitor changes
Connections {
target: Hyprland
function onFocusedMonitorChanged() {
if (!GlobalStates.overviewOpen)
return;
// Transfer the grab to the newly focused monitor
if (root.monitorIsFocused && !grab.active) {
grab.active = true;
} else if (!root.monitorIsFocused && grab.active) {
grab.active = false;
}
}
}
Timer {
id: delayedGrabTimer
interval: Config.options.hacks.arbitraryRaceConditionDelay
repeat: false
onTriggered: {
if (!grab.canBeActive)
return;
grab.active = GlobalStates.overviewOpen;
}
}
// Keep the layershell surface full-screen so backdrop/blur are not constrained by content size.
implicitWidth: screen.width
implicitHeight: screen.height
Item {
id: keyHandler
anchors.fill: parent
visible: GlobalStates.overviewOpen
focus: GlobalStates.overviewOpen
z: 0
Rectangle {
id: backdropLayer
anchors.fill: parent
visible: root.backdropEnabled
color: "#000000"
opacity: root.backdropOpacity
z: 0
}
MouseArea {
id: outsideClickCatcher
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
enabled: root.closeOnFocusLoss && GlobalStates.overviewOpen
z: 0
onPressed: mouse => {
GlobalStates.overviewOpen = false;
mouse.accepted = true;
}
}
Keys.onPressed: event => {
// close: Escape or Enter
if (event.key === Qt.Key_Escape || event.key === Qt.Key_Return) {
GlobalStates.overviewOpen = false;
event.accepted = true;
return;
}
// Helper: compute current group bounds
const workspacesPerGroup = Config.options.overview.rows * Config.options.overview.columns;
const currentId = Hyprland.focusedMonitor?.activeWorkspace?.id ?? 1;
const useWorkspaceMap = Config.options.overview.useWorkspaceMap;
const workspaceMap = Config.options.overview.workspaceMap ?? [];
const focusedMonitorId = Hyprland.focusedMonitor?.id ?? root.monitor?.id ?? 0;
const workspaceOffset = useWorkspaceMap ? Number(workspaceMap[focusedMonitorId] ?? 0) : 0;
const currentGroup = Math.floor((currentId - workspaceOffset - 1) / workspacesPerGroup);
const minWorkspaceId = currentGroup * workspacesPerGroup + 1 + workspaceOffset;
const maxWorkspaceId = minWorkspaceId + workspacesPerGroup - 1;
const rows = Config.options.overview.rows;
const columns = Config.options.overview.columns;
const reverseColumns = Config.options.overview.orderRightLeft;
const reverseRows = Config.options.overview.orderBottomUp;
const clampedIndex = Math.max(0, Math.min(workspacesPerGroup - 1, currentId - minWorkspaceId));
const currentNormalRow = Math.floor(clampedIndex / columns);
const currentNormalColumn = clampedIndex % columns;
function toVisualRow(normalRow) {
return reverseRows ? (rows - normalRow - 1) : normalRow;
}
function toVisualColumn(normalColumn) {
return reverseColumns ? (columns - normalColumn - 1) : normalColumn;
}
function toNormalRow(visualRow) {
return reverseRows ? (rows - visualRow - 1) : visualRow;
}
function toNormalColumn(visualColumn) {
return reverseColumns ? (columns - visualColumn - 1) : visualColumn;
}
let targetVisualRow = toVisualRow(currentNormalRow);
let targetVisualColumn = toVisualColumn(currentNormalColumn);
let targetId = null;
// Arrow keys and vim-style hjkl
if (event.key === Qt.Key_Left || event.key === Qt.Key_H) {
targetVisualColumn = (targetVisualColumn - 1 + columns) % columns;
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_L) {
targetVisualColumn = (targetVisualColumn + 1) % columns;
} else if (event.key === Qt.Key_Up || event.key === Qt.Key_K) {
targetVisualRow = (targetVisualRow - 1 + rows) % rows;
} else if (event.key === Qt.Key_Down || event.key === Qt.Key_J) {
targetVisualRow = (targetVisualRow + 1) % rows;
}
// Number keys: jump to workspace within the current group
// 1-9 map to positions 1-9, 0 maps to position 10
else if (event.key >= Qt.Key_1 && event.key <= Qt.Key_9) {
const position = event.key - Qt.Key_0; // 1-9
if (position <= workspacesPerGroup) {
targetId = minWorkspaceId + position - 1;
}
} else if (event.key === Qt.Key_0) {
// 0 = 10th workspace in the group (if group has 10+ workspaces)
if (workspacesPerGroup >= 10) {
targetId = minWorkspaceId + 9; // 10th position = offset 9
}
}
if (targetId === null && (
event.key === Qt.Key_Left || event.key === Qt.Key_H ||
event.key === Qt.Key_Right || event.key === Qt.Key_L ||
event.key === Qt.Key_Up || event.key === Qt.Key_K ||
event.key === Qt.Key_Down || event.key === Qt.Key_J
)) {
const targetNormalRow = toNormalRow(targetVisualRow);
const targetNormalColumn = toNormalColumn(targetVisualColumn);
targetId = minWorkspaceId + targetNormalRow * columns + targetNormalColumn;
}
if (targetId !== null) {
const clampedTarget = Math.max(minWorkspaceId, Math.min(maxWorkspaceId, targetId));
if (Hyprland.usingLua) {
Hyprland.dispatch(`hl.dsp.focus({workspace = '${clampedTarget}'})`);
} else {
Hyprland.dispatch("workspace " + clampedTarget);
}
event.accepted = true;
}
}
}
ColumnLayout {
id: columnLayout
visible: GlobalStates.overviewOpen
z: 1
anchors {
horizontalCenter: parent.horizontalCenter
top: parent.top
topMargin: Config.options.position.topMargin
}
Loader {
id: overviewLoader
active: Config?.options.overview.enable ?? true
sourceComponent: OverviewWidget {
panelWindow: root
visible: true
}
}
}
}
}
IpcHandler {
target: "overview"
function toggle() {
GlobalStates.overviewOpen = !GlobalStates.overviewOpen;
}
function close() {
GlobalStates.overviewOpen = false;
}
function open() {
GlobalStates.overviewOpen = true;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,265 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import "../../common"
import "../../common/functions"
import "../../services"
import "../.."
Item { // Window
id: root
property var toplevel
property var windowData
property var monitorData
property var widgetMonitorData
property var scale
property var availableWorkspaceWidth
property var availableWorkspaceHeight
property real positionBaseX: (monitorData?.x ?? 0) + (monitorData?.reserved?.[0] ?? 0)
property real positionBaseY: (monitorData?.y ?? 0) + (monitorData?.reserved?.[1] ?? 0)
property int recaptureToken: 0
property bool restrictToWorkspace: true
property real widthRatio: {
if (!widgetMonitorData || !monitorData)
return 1;
const widgetWidth = (widgetMonitorData.transform % 2 === 1) ? (widgetMonitorData.height ?? 1) : (widgetMonitorData.width ?? 1);
const sourceWidth = (monitorData.transform % 2 === 1) ? (monitorData.height ?? 1) : (monitorData.width ?? 1);
const sourceScale = monitorData.scale ?? 1;
const widgetScale = widgetMonitorData.scale ?? 1;
return (widgetWidth * sourceScale) / (sourceWidth * widgetScale);
}
property real heightRatio: {
if (!widgetMonitorData || !monitorData)
return 1;
const widgetHeight = (widgetMonitorData.transform % 2 === 1) ? (widgetMonitorData.width ?? 1) : (widgetMonitorData.height ?? 1);
const sourceHeight = (monitorData.transform % 2 === 1) ? (monitorData.width ?? 1) : (monitorData.height ?? 1);
const sourceScale = monitorData.scale ?? 1;
const widgetScale = widgetMonitorData.scale ?? 1;
return (widgetHeight * sourceScale) / (sourceHeight * widgetScale);
}
property real initX: Math.max(((windowData?.at[0] ?? 0) - positionBaseX) * root.scale * geometryScaleX, 0) + xOffset
property real initY: Math.max(((windowData?.at[1] ?? 0) - positionBaseY) * root.scale * geometryScaleY, 0) + yOffset
property real xOffset: 0
property real yOffset: 0
property int widgetMonitorId: 0
property real geometryScaleX: widthRatio
property real geometryScaleY: heightRatio
property var targetWindowWidth: (windowData?.size[0] ?? 100) * scale * geometryScaleX
property var targetWindowHeight: (windowData?.size[1] ?? 100) * scale * geometryScaleY
property bool hovered: false
property bool pressed: false
property bool showIcons: Config.options.windowPreview.showIcons
property var iconToWindowRatio: Config.options.windowPreview.iconToWindowRatio
property var xwaylandIndicatorToIconRatio: Config.options.windowPreview.xwaylandIndicatorToIconRatio
property var iconToWindowRatioCompact: Config.options.windowPreview.iconToWindowRatioCompact
property bool cropToFill: Config.options.windowPreview.cropToFill
property bool previewsEnabled: Config.options.overview.previewsEnabled
property bool includeInactiveMonitorPreviews: Config.options.overview.includeInactiveMonitorPreviews
property int previewRecaptureDelayMs: Config.options.overview.previewRecaptureDelayMs
property real windowOverlayOpacity: Math.max(0, Math.min(1, Config.options.overview.effects.windowOverlayOpacity))
property bool glassMode: Config.options.overview.effects.glassMode
property real glassShineOpacity: Math.max(0, Math.min(1, Config.options.overview.effects.glassShineOpacity))
property real effectiveWindowOverlayOpacity: glassMode ? Math.min(windowOverlayOpacity, 0.10) : windowOverlayOpacity
property string previewModeRaw: Config.options.overview.previewMode
property string previewMode: {
const mode = `${previewModeRaw ?? "live"}`.trim().toLowerCase();
return (mode === "event" || mode === "snapshot") ? "event" : "live";
}
property bool livePreviewEnabled: previewsEnabled && previewMode === "live"
property bool shouldCapturePreview: {
if (!GlobalStates.overviewOpen || !previewsEnabled || !previewCaptureEnabled)
return false;
if (includeInactiveMonitorPreviews)
return true;
return (windowData?.monitor ?? -1) === widgetMonitorId;
}
property var entry: {
DesktopEntries.applications.values; // re-run when the entry index updates
return DesktopEntries.heuristicLookup(windowData?.class);
}
property string iconName: {
const raw = `${entry?.icon ?? ""}`.trim();
const withoutProviderPrefix = raw.replace(/^image:\/\/icon\//, "");
const withoutQuery = withoutProviderPrefix.split("?")[0].trim();
return withoutQuery.length > 0 ? withoutQuery : "application-x-executable";
}
property var iconPath: Quickshell.iconPath(iconName, "image-missing")
property bool compactMode: Appearance.font.pixelSize.smaller * 4 > targetWindowHeight || Appearance.font.pixelSize.smaller * 4 > targetWindowWidth
property bool indicateXWayland: windowData?.xwayland ?? false
property bool previewCaptureEnabled: true
property bool initialized: false
property bool dragInProgress: false
property bool suspendPositionAnimation: false
property bool animateSize: true
x: initX
y: initY
width: Math.min(targetWindowWidth, availableWorkspaceWidth)
height: Math.min(targetWindowHeight, availableWorkspaceHeight)
opacity: (windowData?.monitor ?? -1) == widgetMonitorId ? 1 : Config.options.windowPreview.inactiveMonitorOpacity
visible: {
const thisWsId = windowData?.workspace?.id;
const isFullscreen = (windowData?.fullscreen ?? 0) > 0;
if (isFullscreen || thisWsId === undefined) return true;
return !HyprlandData.windowList.some(w => w.workspace?.id === thisWsId && (w.fullscreen ?? 0) > 0);
}
clip: true
Component.onCompleted: Qt.callLater(() => root.initialized = true)
Behavior on x {
enabled: root.initialized && !root.dragInProgress && !root.suspendPositionAnimation
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
Behavior on y {
enabled: root.initialized && !root.dragInProgress && !root.suspendPositionAnimation
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
Behavior on width {
enabled: root.initialized && root.animateSize && !root.dragInProgress && !root.suspendPositionAnimation
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
Behavior on height {
enabled: root.initialized && root.animateSize && !root.dragInProgress && !root.suspendPositionAnimation
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
}
// Opaque background for windows on the active monitor.
// The simplest solution for making those windows fully opaque and not interacting with actual
// windows behind the overview, e.g., applying blur to them.
Rectangle {
visible: (root.windowData?.monitor ?? -1) === root.widgetMonitorId
anchors.fill: parent
radius: Appearance.rounding.windowRounding * root.scale
color: root.glassMode
? ColorUtils.mix(Appearance.colors.colLayer2, Appearance.colors.colLayer0, 0.38)
: Appearance.colors.colLayer2
}
ScreencopyView {
id: windowPreview
readonly property real srcAspect: {
const w = root.windowData?.size?.[0] ?? 0;
const h = root.windowData?.size?.[1] ?? 0;
return (w > 0 && h > 0) ? (w / h) : 1;
}
anchors.centerIn: parent
width: root.cropToFill
? Math.max(parent.width, parent.height * srcAspect)
: Math.min(parent.width, parent.height * srcAspect)
height: root.cropToFill
? Math.max(parent.height, parent.width / srcAspect)
: Math.min(parent.height, parent.width / srcAspect)
captureSource: shouldCapturePreview ? root.toplevel : null
live: livePreviewEnabled
layer.enabled: true
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: previewMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
}
}
Rectangle {
anchors.fill: parent
radius: Appearance.rounding.windowRounding * root.scale
color: pressed ? ColorUtils.applyAlpha(Appearance.colors.colLayer2Active, Math.min(1, root.effectiveWindowOverlayOpacity + 0.30)) :
hovered ? ColorUtils.applyAlpha(Appearance.colors.colLayer2Hover, Math.min(1, root.effectiveWindowOverlayOpacity + 0.20)) :
ColorUtils.applyAlpha(
root.glassMode ? ColorUtils.mix(Appearance.colors.colLayer2, Appearance.colors.colLayer0, 0.38) : Appearance.colors.colLayer2,
root.effectiveWindowOverlayOpacity
)
border.color: root.glassMode
? ColorUtils.applyAlpha(Appearance.m3colors.m3outline, 0.62)
: ColorUtils.transparentize(Appearance.m3colors.m3outline, 0.7)
border.width: 1
Rectangle {
visible: root.glassMode
anchors.fill: parent
radius: parent.radius
color: "transparent"
gradient: Gradient {
GradientStop { position: 0.0; color: ColorUtils.applyAlpha("#FFFFFF", root.glassShineOpacity * 0.24) }
GradientStop { position: 0.5; color: ColorUtils.applyAlpha("#FFFFFF", 0.0) }
GradientStop { position: 1.0; color: ColorUtils.applyAlpha("#000000", root.glassShineOpacity * 0.14) }
}
}
Rectangle {
visible: root.glassMode
anchors.fill: parent
anchors.margins: 1
radius: Math.max(parent.radius - 1, 0)
color: "transparent"
border.width: 1
border.color: ColorUtils.applyAlpha("#FFFFFF", root.glassShineOpacity * 0.32)
}
ColumnLayout {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
spacing: Appearance.font.pixelSize.smaller * 0.5
Image {
id: windowIcon
visible: root.showIcons
property var iconSize: {
const renderedSize = Math.min(root.width, root.height);
return renderedSize * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / (root.monitorData?.scale ?? 1);
}
Layout.alignment: Qt.AlignHCenter
source: root.iconPath
width: iconSize
height: iconSize
sourceSize: Qt.size(Math.max(1, Math.round(iconSize)), Math.max(1, Math.round(iconSize)))
}
}
}
Item {
id: previewMask
width: windowPreview.width
height: windowPreview.height
anchors.centerIn: parent
visible: false
layer.enabled: true
layer.smooth: true
Rectangle {
anchors.centerIn: parent
width: root.width
height: root.height
radius: Appearance.rounding.windowRounding * root.scale
}
}
function refreshCapture() {
if (!GlobalStates.overviewOpen || livePreviewEnabled || !previewsEnabled)
return;
root.previewCaptureEnabled = false;
previewResetTimer.restart();
}
Timer {
id: previewResetTimer
interval: Math.max(1, previewRecaptureDelayMs)
repeat: false
onTriggered: root.previewCaptureEnabled = true
}
onRecaptureTokenChanged: {
if (recaptureToken > 0)
root.refreshCapture();
}
}
@@ -0,0 +1,3 @@
Overview 1.0 Overview.qml
OverviewWidget 1.0 OverviewWidget.qml
OverviewWindow 1.0 OverviewWindow.qml