commit 56e07449a8daeee0622035fbba2d7a4f7350a8b2 Author: Tim Wundenberg Date: Wed Apr 15 09:23:45 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfbea23 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.qmlls.ini diff --git a/bar/Bar.qml b/bar/Bar.qml new file mode 100644 index 0000000..748d2ee --- /dev/null +++ b/bar/Bar.qml @@ -0,0 +1,44 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts + +PanelWindow { + id: root + + anchors { + top: true + left: true + right: true + } + + implicitHeight: 32 + exclusionMode: ExclusionMode.Auto + + color: "#1e1e2e" + + // Left side — anchored to left edge, never affected by right side width + RowLayout { + anchors { left: parent.left; top: parent.top; bottom: parent.bottom; leftMargin: 8 } + Workspaces { + screen: root.screen + } + } + + // Right side — anchored to right edge, always stays there regardless of tray width + RowLayout { + anchors { right: parent.right; top: parent.top; bottom: parent.bottom; rightMargin: 8 } + spacing: 16 + + SystemStats {} + + DateTime { + barWindow: root + } + + Tray { + barWindow: root + } + } +} diff --git a/bar/CalendarPopup.qml b/bar/CalendarPopup.qml new file mode 100644 index 0000000..b4d5e4e --- /dev/null +++ b/bar/CalendarPopup.qml @@ -0,0 +1,268 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts + +// Calendar popup — anchored below its trigger item. +// Instantiate inside DateTime.qml; set anchor.window + anchor.item from there. +// Use open() / close() instead of toggling visible directly. + +PopupWindow { + id: root + + // Transparent window so the rounded Rectangle below animates as one unit. + visible: false + color: "transparent" + + implicitWidth: 252 // 7 columns × 36 px + implicitHeight: 284 + + // ── State ──────────────────────────────────────────────────────────────── + property int viewYear: new Date().getFullYear() + property int viewMonth: new Date().getMonth() // 0-indexed + + // ── Public API ─────────────────────────────────────────────────────────── + function open() { + // Reset animation state before the window becomes visible. + content.opacity = 0; + content.scale = 0.95; + closeAnim.stop(); + visible = true; // onVisibleChanged starts openAnim + } + + function close() { + openAnim.stop(); + closeAnim.start(); // sets visible = false when done + } + + onVisibleChanged: { + if (visible) { + const now = new Date(); + viewYear = now.getFullYear(); + viewMonth = now.getMonth(); + openAnim.restart(); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + readonly property var monthNames: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + + function prevMonth() { + if (viewMonth === 0) { + viewMonth = 11; + viewYear--; + } else + viewMonth--; + } + function nextMonth() { + if (viewMonth === 11) { + viewMonth = 0; + viewYear++; + } else + viewMonth++; + } + + // ── Animated content wrapper ────────────────────────────────────────────── + Item { + id: content + anchors.fill: parent + + opacity: 0 + scale: 0.95 + transformOrigin: Item.Top // grows downward from the anchor edge + + // Open: fade + scale up + ParallelAnimation { + id: openAnim + NumberAnimation { + target: content + property: "opacity" + to: 1.0 + duration: 150 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: content + property: "scale" + to: 1.0 + duration: 150 + easing.type: Easing.OutCubic + } + } + + // Close: fade + scale down, then hide the window + ParallelAnimation { + id: closeAnim + NumberAnimation { + target: content + property: "opacity" + to: 0.0 + duration: 100 + easing.type: Easing.InCubic + } + NumberAnimation { + target: content + property: "scale" + to: 0.95 + duration: 100 + easing.type: Easing.InCubic + } + onFinished: root.visible = false + } + + // ── Background ─────────────────────────────────────────────────────── + Rectangle { + anchors.fill: parent + color: "#181825" // Catppuccin Mantle + radius: 8 + } + + // ── Layout ─────────────────────────────────────────────────────────── + Column { + anchors { + fill: parent + margins: 12 + } + spacing: 8 + + // ── Month navigation ───────────────────────────────────────────── + RowLayout { + width: parent.width + + Rectangle { + width: 24 + height: 24 + radius: 4 + color: prevHover.hovered ? "#313244" : "transparent" + Text { + anchors.centerIn: parent + text: "‹" + color: "#89b4fa" + font.pixelSize: 16 + font.weight: Font.Bold + } + HoverHandler { + id: prevHover + } + TapHandler { + onTapped: root.prevMonth() + } + } + + Item { + Layout.fillWidth: true + } + + Text { + text: root.monthNames[root.viewMonth] + " " + root.viewYear + color: "#cdd6f4" + font.pixelSize: 13 + font.weight: Font.Medium + } + + Item { + Layout.fillWidth: true + } + + Rectangle { + width: 24 + height: 24 + radius: 4 + color: nextHover.hovered ? "#313244" : "transparent" + Text { + anchors.centerIn: parent + text: "›" + color: "#89b4fa" + font.pixelSize: 16 + font.weight: Font.Bold + } + HoverHandler { + id: nextHover + } + TapHandler { + onTapped: root.nextMonth() + } + } + } + + // ── Day-of-week header ──────────────────────────────────────────── + Row { + width: parent.width + Repeater { + model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + Text { + required property string modelData + required property int index + width: 36 + horizontalAlignment: Text.AlignHCenter + text: modelData + color: index >= 5 ? "#f38ba8" : "#6c7086" + font.pixelSize: 11 + } + } + } + + // ── Day grid ────────────────────────────────────────────────────── + Grid { + id: calGrid + columns: 7 + columnSpacing: 0 + rowSpacing: 0 + width: parent.width + + readonly property int firstWeekday: { + const d = new Date(root.viewYear, root.viewMonth, 1).getDay(); + return (d + 6) % 7; + } + readonly property int daysInMonth: new Date(root.viewYear, root.viewMonth + 1, 0).getDate() + readonly property int cellCount: 6 * 7 + + Repeater { + model: calGrid.cellCount + + delegate: Item { + id: cell + required property int index + + readonly property int day: index - calGrid.firstWeekday + 1 + readonly property bool inMonth: day >= 1 && day <= calGrid.daysInMonth + readonly property bool isToday: { + if (!inMonth) + return false; + const now = new Date(); + return day === now.getDate() && root.viewMonth === now.getMonth() && root.viewYear === now.getFullYear(); + } + readonly property bool isWeekend: index % 7 >= 5 + + width: 36 + height: 36 + + Rectangle { + anchors.centerIn: parent + width: 28 + height: 28 + radius: 14 + color: cell.isToday ? "#89b4fa" : "transparent" + } + + Text { + anchors.centerIn: parent + visible: cell.inMonth + text: cell.day + font.pixelSize: 12 + font.weight: cell.isToday ? Font.Bold : Font.Normal + color: { + if (cell.isToday) + return "#1e1e2e"; + if (cell.isWeekend) + return "#f38ba8"; + return "#cdd6f4"; + } + } + } + } + } + } + } +} diff --git a/bar/DateTime.qml b/bar/DateTime.qml new file mode 100644 index 0000000..a664d66 --- /dev/null +++ b/bar/DateTime.qml @@ -0,0 +1,66 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts + +// Date + time widget. Click it to open/close the calendar popup. +// barWindow must be set by the parent (Bar.qml) so the popup has a Wayland parent. + +RowLayout { + id: root + + required property var barWindow + + spacing: 8 + implicitHeight: 32 + + CalendarPopup { + id: cal + + anchor.window: root.barWindow + anchor.item: root + anchor.edges: Edges.Bottom + } + + Text { + id: dateLabel + color: "#a6adc8" + font.pixelSize: 13 + } + + Text { + text: "|" + color: "#45475a" + font.pixelSize: 13 + } + + Text { + id: timeLabel + color: "#cdd6f4" + font.pixelSize: 13 + font.weight: Font.Medium + Behavior on color { + ColorAnimation { + duration: 80 + } + } + } + + Timer { + interval: 15000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { + const now = new Date(); + const de = Qt.locale("de_DE"); + dateLabel.text = now.toLocaleDateString(de, "ddd, d. MMM"); + timeLabel.text = Qt.formatTime(now, "HH:mm"); + } + } + + TapHandler { + onTapped: cal.visible ? cal.close() : cal.open() + } +} diff --git a/bar/SystemStats.qml b/bar/SystemStats.qml new file mode 100644 index 0000000..7347c4a --- /dev/null +++ b/bar/SystemStats.qml @@ -0,0 +1,233 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Layouts + +// System stats strip: Volume · Network · CPU load · Memory · Temperature · Brightness · Battery +// All stats refreshed every 5 s via a single bash process. +// Requires: pactl, ip, awk, nproc, acpi, coreutils. Battery/backlight items hide when unavailable. + +RowLayout { + id: root + spacing: 16 // space between stat groups + + // ── State ───────────────────────────────────────────────────────────────── + property int volPct: 0 + property bool volMuted: false + property real loadAvg: 0 + property int cpuCores: 1 + property real memUsedGB: 0 + property real memTotalGB: 0 + property int tempC: 0 + property int brightPct: 100 + property int batPct: -1 // -1 means no battery detected + property string batStatus: "" + property string batTime: "" // "H:MM" remaining, empty when unknown + property string netIp: "" + + // ── Line parser ─────────────────────────────────────────────────────────── + function parseLine(line) { + if (line.startsWith("vol:")) { + const p = line.split(":"); + root.volPct = parseInt(p[1]) || 0; + root.volMuted = p[2] === "1"; + + } else if (line.startsWith("cpu:")) { + const p = line.split(":"); + root.loadAvg = parseFloat(p[1]) || 0; + root.cpuCores = parseInt(p[2]) || 1; + + } else if (line.startsWith("mem:")) { + const p = line.split(":"); + root.memUsedGB = parseFloat(p[1]) || 0; + root.memTotalGB = parseFloat(p[2]) || 0; + + } else if (line.startsWith("temp:")) { + root.tempC = parseInt(line.slice(5)) || 0; + + } else if (line.startsWith("bright:")) { + root.brightPct = parseInt(line.slice(7)) || 100; + + } else if (line.startsWith("bat:")) { + const p = line.split(":"); + const cap = parseInt(p[1]); + root.batPct = isNaN(cap) ? -1 : cap; + root.batStatus = p[2] || ""; + root.batTime = p.slice(3).join(":") || ""; // rejoin H:MM split by delimiter + + } else if (line.startsWith("net:")) { + root.netIp = line.slice(4).trim(); + } + } + + // ── Process ─────────────────────────────────────────────────────────────── + Process { + id: statsProc + command: [ + "bash", "-c", + // Volume + "vol=$(pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oP '\\d+(?=%)' | head -1); " + + "muted=$(pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null | grep -q yes && echo 1 || echo 0); " + + // CPU: 1-min load average + core count + "load=$(cut -d' ' -f1 /proc/loadavg); " + + "cores=$(nproc); " + + // Memory: used and total in GB + "mem=$(awk '/MemTotal/{t=$2}/MemAvailable/{a=$2}END{printf \"%.2f:%.2f\",(t-a)/1048576,t/1048576}' /proc/meminfo); " + + // Temperature (highest thermal zone; falls back to hwmon) + "temps=$(cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | sort -n | tail -1); " + + "[ -z \"$temps\" ] && temps=$(cat /sys/class/hwmon/hwmon*/temp1_input 2>/dev/null | sort -n | tail -1); " + + // Brightness + "bright_cur=$(cat /sys/class/backlight/*/brightness 2>/dev/null | head -1); " + + "bright_max=$(cat /sys/class/backlight/*/max_brightness 2>/dev/null | head -1); " + + "if [ -n \"$bright_cur\" ] && [ -n \"$bright_max\" ] && [ \"$bright_max\" -gt 0 ]; " + + "then bright=$((bright_cur*100/bright_max)); else bright=100; fi; " + + // Battery capacity + status + estimated time (via acpi, H:MM only) + "bat=$(cat /sys/class/power_supply/BAT*/capacity 2>/dev/null | head -1); " + + "bat_st=$(cat /sys/class/power_supply/BAT*/status 2>/dev/null | head -1); " + + "bat_time=$(acpi -b 2>/dev/null | grep -oP '\\d+:\\d+:\\d+' | head -1 | cut -d: -f1-2); " + + // Network (outbound IP) + "net=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \\K[\\d.]+' | head -1); " + + // Normalise + "temp=$([ -n \"$temps\" ] && echo $((temps/1000)) || echo 0); " + + "[ -z \"$vol\" ] && vol=0; [ -z \"$bat\" ] && bat=-1; " + + // Output + "printf \"vol:%s:%s\\ncpu:%s:%s\\nmem:%s\\ntemp:%s\\nbright:%s\\nbat:%s:%s:%s\\nnet:%s\\n\" " + + "\"$vol\" \"$muted\" \"$load\" \"$cores\" \"$mem\" \"$temp\" \"$bright\" \"$bat\" \"$bat_st\" \"$bat_time\" \"$net\"" + ] + stdout: SplitParser { + onRead: function(line) { root.parseLine(line) } + } + } + + Timer { + interval: 5000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { + statsProc.running = false + statsProc.running = true + } + } + + // ── Volume ──────────────────────────────────────────────────────────────── + RowLayout { + spacing: 4 + + Process { id: pavucontrolProc; command: ["pavucontrol"] } + Process { id: volUpProc; command: ["pactl", "set-sink-volume", "@DEFAULT_SINK@", "+5%"] } + Process { id: volDownProc; command: ["pactl", "set-sink-volume", "@DEFAULT_SINK@", "-5%"] } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: if (!pavucontrolProc.running) pavucontrolProc.running = true + onWheel: function(wheel) { + if (wheel.angleDelta.y > 0) { + volUpProc.running = false + volUpProc.running = true + root.volPct = Math.min(100, root.volPct + 5) + } else { + volDownProc.running = false + volDownProc.running = true + root.volPct = Math.max(0, root.volPct - 5) + } + } + } + + Text { + text: root.volMuted ? "󰖁" : (root.volPct > 50 ? "󰕾" : root.volPct > 0 ? "󰖀" : "󰕿") + color: root.volMuted ? "#585b70" : "#cdd6f4" + font.pixelSize: 15 + } + Text { + text: root.volMuted ? "mute" : root.volPct + "%" + color: "#a6adc8" + font.pixelSize: 13 + } + } + + // ── Network ─────────────────────────────────────────────────────────────── + RowLayout { + spacing: 4 + Text { + text: root.netIp ? "󰱓" : "󰲛" + color: root.netIp ? "#cdd6f4" : "#585b70" + font.pixelSize: 15 + } + Text { + visible: root.netIp !== "" + text: root.netIp + color: "#a6adc8" + font.pixelSize: 13 + } + } + + // ── CPU load average ────────────────────────────────────────────────────── + RowLayout { + spacing: 4 + Text { text: "󰻠"; color: "#cdd6f4"; font.pixelSize: 15 } + Text { + text: root.loadAvg.toFixed(2) + color: root.loadAvg > root.cpuCores ? "#f38ba8" : + root.loadAvg > root.cpuCores * 0.7 ? "#fab387" : "#a6adc8" + font.pixelSize: 13 + } + } + + // ── Memory ──────────────────────────────────────────────────────────────── + RowLayout { + spacing: 4 + Text { text: "󰍛"; color: "#cdd6f4"; font.pixelSize: 15 } + Text { + text: root.memUsedGB.toFixed(1) + "/" + root.memTotalGB.toFixed(1) + "G" + color: root.memTotalGB > 0 && (root.memUsedGB / root.memTotalGB) > 0.8 ? "#f38ba8" : + root.memTotalGB > 0 && (root.memUsedGB / root.memTotalGB) > 0.6 ? "#fab387" : "#a6adc8" + font.pixelSize: 13 + } + } + + // ── Temperature ─────────────────────────────────────────────────────────── + RowLayout { + spacing: 4 + Text { text: "󰔐"; color: "#cdd6f4"; font.pixelSize: 15 } + Text { + text: root.tempC + "°" + color: root.tempC > 85 ? "#f38ba8" : root.tempC > 65 ? "#fab387" : "#a6adc8" + font.pixelSize: 13 + } + } + + // ── Brightness ──────────────────────────────────────────────────────────── + RowLayout { + spacing: 4 + Text { text: "󰃟"; color: "#cdd6f4"; font.pixelSize: 15 } + Text { + text: root.brightPct + "%" + color: "#a6adc8" + font.pixelSize: 13 + } + } + + // ── Battery (hidden on desktops without a battery) ──────────────────────── + RowLayout { + spacing: 4 + visible: root.batPct >= 0 + Text { + text: root.batStatus === "Charging" ? "󰂄" : + root.batPct > 80 ? "󰁹" : + root.batPct > 60 ? "󰁾" : + root.batPct > 40 ? "󰁼" : + root.batPct > 20 ? "󰁺" : "󰂃" + color: root.batPct < 20 && root.batStatus !== "Charging" ? "#f38ba8" : "#cdd6f4" + font.pixelSize: 15 + } + Text { + text: root.batTime ? root.batPct + "% · " + root.batTime : root.batPct + "%" + color: root.batPct < 20 && root.batStatus !== "Charging" ? "#f38ba8" : "#a6adc8" + font.pixelSize: 13 + } + } +} diff --git a/bar/Tray.qml b/bar/Tray.qml new file mode 100644 index 0000000..926cbba --- /dev/null +++ b/bar/Tray.qml @@ -0,0 +1,57 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: root + spacing: 6 + + required property var barWindow + + Repeater { + model: SystemTray.items + + delegate: Item { + id: trayDelegate + required property SystemTrayItem modelData + + implicitWidth: 20 + implicitHeight: 20 + + IconImage { + anchors.centerIn: parent + source: trayDelegate.modelData.icon + width: 16 + height: 16 + mipmap: true + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) { + trayDelegate.modelData.display( + root.barWindow, + Math.round(trayDelegate.mapToGlobal(0, 0).x), + 32) + } else if (mouse.button === Qt.MiddleButton) { + trayDelegate.modelData.secondaryActivate() + } else { + if (trayDelegate.modelData.onlyMenu) + trayDelegate.modelData.display( + root.barWindow, + Math.round(trayDelegate.mapToGlobal(0, 0).x), + 32) + else + trayDelegate.modelData.activate() + } + } + } + } + } +} diff --git a/bar/Workspaces.qml b/bar/Workspaces.qml new file mode 100644 index 0000000..b082ad6 --- /dev/null +++ b/bar/Workspaces.qml @@ -0,0 +1,72 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import QtQuick +import QtQuick.Layouts + +// A row of clickable workspace buttons for one screen. +// The button for the workspace currently active on *this* screen is highlighted. + +RowLayout { + id: root + + required property var screen + + spacing: 4 + + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(screen) + + Repeater { + model: Hyprland.workspaces + + delegate: Rectangle { + id: btn + + required property HyprlandWorkspace modelData + + readonly property bool isActive: root.monitor ? (root.monitor.activeWorkspace?.id === modelData.id) : modelData.focused + + implicitWidth: Math.max(28, label.implicitWidth + 16) + implicitHeight: 22 + radius: 4 + + color: { + if (isActive) + return "#89b4fa"; + if (hoverArea.hovered) + return "#45475a"; + return "#313244"; + } + + Behavior on color { + ColorAnimation { + duration: 80 + } + } + + Text { + id: label + anchors.centerIn: parent + text: btn.modelData.name + color: btn.isActive ? "#1e1e2e" : "#cdd6f4" // Base / Text + font.pixelSize: 13 + font.weight: btn.isActive ? Font.Bold : Font.Normal + + Behavior on color { + ColorAnimation { + duration: 80 + } + } + } + + HoverHandler { + id: hoverArea + } + + TapHandler { + onTapped: btn.modelData.activate() + } + } + } +} diff --git a/shell.qml b/shell.qml new file mode 100644 index 0000000..914ea15 --- /dev/null +++ b/shell.qml @@ -0,0 +1,18 @@ +//@ pragma UseQApplication +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import "bar" + +// Root scope — creates one Bar per connected screen. +Scope { + Variants { + model: Quickshell.screens + + Bar { + required property var modelData + screen: modelData + } + } +}