From 3a4d8db003bfd6708eff8d97540fffa44da91b08 Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 26 May 2026 20:34:03 +0100 Subject: [PATCH] quickshell: modularize QML into separate files Co-Authored-By: Claude Opus 4.6 --- settings/hyprland.nix | 2837 +++++++++++++++++++++-------------------- 1 file changed, 1444 insertions(+), 1393 deletions(-) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 46a77de..06dcd8f 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -441,1402 +441,1453 @@ in Install.WantedBy = [ "hyprland-session.target" ]; }; - xdg.configFile."quickshell/shell.qml" = { - onChange = '' + xdg.configFile = let + qsRestart = '' ${pkgs.systemd}/bin/systemctl --user restart quickshell.service 2>/dev/null || true ''; - text = '' - //@ pragma UseQApplication - import Quickshell - import Quickshell.Hyprland - import Quickshell.Services.SystemTray - import Quickshell.Widgets - import Quickshell.Io - import Quickshell.Services.Notifications - import QtQuick - import QtQuick.Layouts - import Qt5Compat.GraphicalEffects - - ShellRoot { - id: root - property var latestNotification: null - signal notificationReceived() - - NotificationServer { - id: notifServer - bodySupported: true - actionsSupported: true - imageSupported: true - persistenceSupported: true - keepOnReload: true - onNotification: (notification) => { - notification.tracked = true; - root.latestNotification = notification; - root.notificationReceived(); - } - } - - Variants { - model: Quickshell.screens - - PanelWindow { - id: bar - required property var modelData - screen: modelData - - anchors { - top: true - left: true - right: true - } - - implicitHeight: 30 - color: "#D1${c.base00}" - - property var activeDropdown: null - function closeAllDropdowns() { - if (activeDropdown && activeDropdown.visible) { - activeDropdown.animateClose(); - } - activeDropdown = null; - } - function toggleDropdown(dd, setupFn) { - if (dd.visible && !dd.closing) { - dd.animateClose(); - activeDropdown = null; - } else { - if (activeDropdown && activeDropdown !== dd && activeDropdown.visible) { - activeDropdown.animateClose(); - } - if (setupFn) setupFn(); - if (dd.closing) { - // Cancel close animation, reopen - dd.closing = false; - dd.open = true; - } else { - dd.visible = true; - } - activeDropdown = dd; - } - } - - // Left — workspaces - Row { - anchors.left: parent.left - anchors.leftMargin: 6 - anchors.verticalCenter: parent.verticalCenter - spacing: 0 - - Repeater { - model: Hyprland.workspaces - - Item { - required property var modelData - width: 28 - height: 30 - - Text { - anchors.centerIn: parent - text: modelData.name - color: modelData.focused ? "#${c.base05}" : "#${c.base03}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - } - - Rectangle { - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - 8 - height: 2 - color: "#${c.base05}" - visible: modelData.focused - } - - MouseArea { - anchors.fill: parent - onClicked: modelData.activate() - } - } - } - } - - // Center — clock - Text { - id: clockText - anchors.centerIn: parent - property date now: new Date() - text: now.toLocaleTimeString(Qt.locale(), "HH:mm") - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - - Timer { - interval: 1000 - running: true - repeat: true - onTriggered: clockText.now = new Date() - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - onClicked: bar.toggleDropdown(calPopup) - onEntered: { if (bar.activeDropdown && bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup); } - } - } - - // Right — network, battery, tray - Row { - anchors.right: parent.right - anchors.rightMargin: 8 - anchors.verticalCenter: parent.verticalCenter - spacing: 10 - - // Network status - Item { - id: netWidget - width: 16 - height: 30 - - property string netState: "disconnected" - property string netConn: "" - property string netType: "" - property string netIcon: "\u{f0b0}" - - Timer { - interval: 5000 - running: true - repeat: true - triggeredOnStart: true - onTriggered: netWidget.refreshNet() - } - - property string _pendingState: "disconnected" - property string _pendingConn: "" - property string _pendingType: "" - property string _pendingDevice: "" - - function refreshNet() { - netWidget._pendingState = "disconnected"; - netWidget._pendingConn = ""; - netWidget._pendingType = ""; - netProc.running = true; - } - - Process { - id: netProc - command: ["${pkgs.networkmanager}/bin/nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"] - stdout: SplitParser { - onRead: data => { - let fields = data.split(":"); - if (fields.length < 4) return; - let type = fields[1]; - let state = fields[2]; - let conn = fields[3]; - if (type !== "ethernet" && type !== "wifi") return; - if (type === "wifi") { - netWidget._pendingDevice = fields[0]; - } - if (state === "connected") { - netWidget._pendingState = "connected"; - netWidget._pendingConn = conn; - netWidget._pendingType = type; - } - } - } - onRunningChanged: { - if (!running) { - netWidget.netState = netWidget._pendingState; - netWidget.netConn = netWidget._pendingConn; - netWidget.netType = netWidget._pendingType.length > 0 ? netWidget._pendingType : netWidget.netType; - netWidget.netDevice = netWidget._pendingDevice.length > 0 ? netWidget._pendingDevice : netWidget.netDevice; - if (netWidget.netState === "connected") { - netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05a9}" : "\u{f0200}"; - } else { - netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05aa}" : "\u{f0201}"; - } - } - } - } - - Text { - anchors.centerIn: parent - text: netWidget.netIcon - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 14 - } - - property var wifiNetworks: [] - property string netDevice: "" - - Timer { - id: netRefreshDelay - interval: 2000 - onTriggered: netWidget.refreshNet() - } - - property var _pendingNets: [] - - Process { - id: wifiScanProc - command: ["${pkgs.networkmanager}/bin/nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "device", "wifi", "list", "--rescan", "auto"] - stdout: SplitParser { - onRead: data => { - let fields = data.split(":"); - if (fields.length < 4 || fields[0] === "") return; - for (let i = 0; i < netWidget._pendingNets.length; i++) { - if (netWidget._pendingNets[i].ssid === fields[0]) return; - } - netWidget._pendingNets.push({ - ssid: fields[0], - signal: parseInt(fields[1]) || 0, - security: fields[2], - active: fields[3] === "*" - }); - } - } - onRunningChanged: { - if (!running) { - netWidget.wifiNetworks = netWidget._pendingNets; - netWidget._pendingNets = []; - } - } - } - - Process { - id: wifiConnectProc - property string targetSsid: "" - command: ["${pkgs.writeShellScript "wifi-connect" '' - ssid="$1" - ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" 2>/dev/null && exit 0 - pw=$(${pkgs.zenity}/bin/zenity --password --title="WiFi Password" 2>/dev/null) - [ -n "$pw" ] && ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" password "$pw" - ''}", targetSsid] - } - - Process { - id: netDisconnectProc - property string targetDevice: "" - command: ["${pkgs.networkmanager}/bin/nmcli", "device", "disconnect", targetDevice] - } - - - function openNetDropdown() { - bar.toggleDropdown(netDropdown, function() { - wifiScanProc.running = true; - let pos = netWidget.mapToItem(bar.contentItem, netWidget.width / 2, 0); - netDropdown.dropdownX = pos.x; - }); - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - onClicked: netWidget.openNetDropdown() - onEntered: { if (bar.activeDropdown && bar.activeDropdown !== netDropdown) netWidget.openNetDropdown(); } - } - } - - ${lib.optionalString isMacbook '' - // Battery - Item { - id: batteryWidget - width: batteryText.width + 4 + batteryIconText.width - height: 30 - - property int batteryLevel: 0 - property bool charging: false - property string batteryIcon: "\u{f008e}" - property real powerDraw: 0.0 - property real energyNow: 0.0 - property real energyFull: 0.0 - property string timeRemaining: "" - property string powerProfile: "balanced" - - function updateIcon() { - if (charging) { batteryIcon = "\u{f0084}"; return; } - if (batteryLevel >= 90) batteryIcon = "\u{f0079}"; - else if (batteryLevel >= 70) batteryIcon = "\u{f0082}"; - else if (batteryLevel >= 50) batteryIcon = "\u{f007f}"; - else if (batteryLevel >= 30) batteryIcon = "\u{f007c}"; - else if (batteryLevel >= 15) batteryIcon = "\u{f007a}"; - else batteryIcon = "\u{f008e}"; - } - - Timer { - interval: 5000 - running: true - repeat: true - triggeredOnStart: true - onTriggered: { batteryProc.running = true; profileProc.running = true; } - } - - Process { - id: batteryProc - command: ["sh", "-c", "cat /sys/class/power_supply/BAT0/capacity; cat /sys/class/power_supply/BAT0/status; cat /sys/class/power_supply/BAT0/power_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_full 2>/dev/null || echo 0"] - stdout: SplitParser { - property int lineNum: 0 - onRead: data => { - let trimmed = data.trim(); - let num = parseInt(trimmed); - lineNum++; - if (lineNum === 1) { - if (!isNaN(num)) batteryWidget.batteryLevel = num; - } else if (lineNum === 2) { - batteryWidget.charging = (trimmed === "Charging"); - } else if (lineNum === 3) { - if (!isNaN(num)) batteryWidget.powerDraw = num / 1000000.0; - } else if (lineNum === 4) { - if (!isNaN(num)) batteryWidget.energyNow = num / 1000000.0; - } else if (lineNum === 5) { - if (!isNaN(num)) batteryWidget.energyFull = num / 1000000.0; - lineNum = 0; - // Calculate time remaining - if (batteryWidget.powerDraw > 0.5) { - let hours; - if (batteryWidget.charging) { - hours = (batteryWidget.energyFull - batteryWidget.energyNow) / batteryWidget.powerDraw; - } else { - hours = batteryWidget.energyNow / batteryWidget.powerDraw; - } - let h = Math.floor(hours); - let m = Math.round((hours - h) * 60); - batteryWidget.timeRemaining = h + "h " + m + "m"; - } else { - batteryWidget.timeRemaining = ""; - } - } - batteryWidget.updateIcon(); - } - } - } - - Process { - id: profileProc - command: ["${pkgs.power-profiles-daemon}/bin/powerprofilesctl", "get"] - stdout: SplitParser { - onRead: data => { - batteryWidget.powerProfile = data.trim(); - } - } - } - - Process { - id: setProfileProc - property string target: "balanced" - command: ["${pkgs.power-profiles-daemon}/bin/powerprofilesctl", "set", target] - } - - Row { - anchors.verticalCenter: parent.verticalCenter - spacing: 4 - - Text { - id: batteryText - text: batteryWidget.batteryLevel + "%" - color: batteryWidget.batteryLevel <= 15 ? "#${c.base08}" - : batteryWidget.batteryLevel <= 30 ? "#${c.base0A}" - : "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - } - - Text { - id: batteryIconText - text: batteryWidget.batteryIcon - color: batteryWidget.batteryLevel <= 15 ? "#${c.base08}" - : batteryWidget.batteryLevel <= 30 ? "#${c.base0A}" - : "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 14 - } - } - - function openBatteryDropdown() { - bar.toggleDropdown(batteryDropdown, function() { - batteryProc.running = true; - profileProc.running = true; - let pos = batteryWidget.mapToItem(bar.contentItem, batteryWidget.width / 2, 0); - batteryDropdown.dropdownX = pos.x; - }); - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - onClicked: batteryWidget.openBatteryDropdown() - onEntered: { if (bar.activeDropdown && bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown(); } - } - } - ''} - - // Tray icons - Row { - id: trayArea - spacing: 8 - anchors.verticalCenter: parent.verticalCenter - - Repeater { - model: SystemTray.items - - Item { - required property var modelData - width: 16 - height: 16 - - Image { - id: trayIcon - anchors.fill: parent - source: modelData.icon - sourceSize.width: 16 - sourceSize.height: 16 - smooth: true - mipmap: true - visible: false - } - - ColorOverlay { - anchors.fill: trayIcon - source: trayIcon - color: "#${c.base05}" - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.NoButton - onEntered: { - if (bar.activeDropdown && modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) { - bar.toggleDropdown(contextMenu, function() { - let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); - contextMenu.dropdownX = pos.x; - contextMenu.trayItem = modelData; - menuOpener.menu = modelData.menu; - }); - } - } - } - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: (event) => { - if (modelData.hasMenu) { - bar.toggleDropdown(contextMenu, function() { - let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); - contextMenu.dropdownX = pos.x; - contextMenu.trayItem = modelData; - menuOpener.menu = modelData.menu; - }); - } else { - modelData.activate(); - } - } - } - } - } - } - } - - // Reusable dropdown component - component BarDropdown: PopupWindow { - id: dropdown - property bool open: false - property bool closing: false - property real dropdownX: 0 - property real fullWidth: 200 - property real fullHeight: 200 - property int autoCloseMs: 1500 - default property alias content: dropdownContent.data - - function animateClose() { - if (!visible || closing) return; - closing = true; - open = false; - _autoClose.stop(); - _closeDelay.start(); - } - - anchor.window: bar - anchor.rect.x: dropdownX - (fullWidth + 16) / 2 - anchor.rect.y: bar.height - anchor.edges: Edges.Top | Edges.Left - anchor.gravity: Edges.Bottom | Edges.Right - anchor.adjustment: PopupAdjustment.Slide - visible: false - color: "transparent" - implicitWidth: fullWidth + 16 - implicitHeight: fullHeight + 4 - - onVisibleChanged: { - if (visible) { - closing = false; - open = true; - _autoClose.restart(); - } else { - open = false; - closing = false; - _autoClose.stop(); - } - } - - Timer { - id: _autoClose - interval: dropdown.autoCloseMs - onTriggered: bar.closeAllDropdowns() - } - - Timer { - id: _closeDelay - interval: 230 - onTriggered: { dropdown.visible = false; dropdown.closing = false; } - } - - HoverHandler { - onHoveredChanged: { - if (hovered) _autoClose.stop(); - else _autoClose.restart(); - } - } - - Item { - anchors.right: _dropdownRect.left - anchors.top: parent.top - width: 8 - height: Math.min(8, _dropdownRect.height) - clip: true - visible: _dropdownRect.height > 0 - Canvas { - anchors.top: parent.top - width: 8; height: 8 - onPaint: { - var ctx = getContext("2d"); - ctx.clearRect(0, 0, 8, 8); - ctx.fillStyle = "#D1${c.base00}"; - ctx.beginPath(); - ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.closePath(); ctx.fill(); - } - } - } - - Item { - anchors.left: _dropdownRect.right - anchors.top: parent.top - width: 8 - height: Math.min(8, _dropdownRect.height) - clip: true - visible: _dropdownRect.height > 0 - Canvas { - anchors.top: parent.top - width: 8; height: 8 - onPaint: { - var ctx = getContext("2d"); - ctx.clearRect(0, 0, 8, 8); - ctx.fillStyle = "#D1${c.base00}"; - ctx.beginPath(); - ctx.moveTo(0, 0); ctx.lineTo(8, 0); - ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); - ctx.closePath(); ctx.fill(); - } - } - } - - Rectangle { - id: _dropdownRect - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - width: dropdown.fullWidth - height: dropdown.open ? dropdown.fullHeight : 0 - color: "#D1${c.base00}" - radius: 8 - topLeftRadius: 0 - topRightRadius: 0 - clip: true - - Behavior on height { - NumberAnimation { duration: 220; easing.type: Easing.OutCubic } - } - - Item { - id: dropdownContent - anchors.fill: parent - } - } - } - - // Context menu - BarDropdown { - id: contextMenu - property var trayItem: null - fullWidth: menuItems.width + 16 - fullHeight: menuItems.height + 12 - - onVisibleChanged: { - if (!visible) menuOpener.menu = null; - } - - QsMenuOpener { - id: menuOpener - } - - Column { - id: menuItems - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: 6 - width: 200 - - Repeater { - model: menuOpener.children - - Rectangle { - required property var modelData - width: 200 - height: modelData.isSeparator ? 9 : 28 - color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled - ? "#${c.base02}" : "transparent" - radius: modelData.isSeparator ? 0 : 4 - - Rectangle { - visible: modelData.isSeparator - anchors.centerIn: parent - width: parent.width - 20 - height: 1 - color: "#${c.base03}" - } - - RowLayout { - visible: !modelData.isSeparator - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 8 - - Text { - Layout.fillWidth: true - text: modelData.text ?? "" - color: modelData.enabled ? "#${c.base05}" : "#${c.base03}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - elide: Text.ElideRight - } - - Text { - visible: modelData.buttonType !== QsMenuButtonType.None - text: modelData.checkState === Qt.Checked ? "\u2713" : "" - color: "#${c.base0D}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - } - } - - MouseArea { - id: itemMouse - anchors.fill: parent - hoverEnabled: true - enabled: !modelData.isSeparator && modelData.enabled - onClicked: { - modelData.triggered(); - bar.closeAllDropdowns(); - } - } - } - } - } - } - - // Network dropdown - BarDropdown { - id: netDropdown - fullWidth: netDropdownCol.width + 24 - fullHeight: netDropdownCol.height + 16 - - Column { - id: netDropdownCol - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: 8 - width: 220 - spacing: 4 - - // Current connection header - Text { - width: parent.width - text: netWidget.netState === "connected" - ? "\u{f05a9} " + netWidget.netConn - : "\u{f05aa} Not connected" - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - elide: Text.ElideRight - } - - // Disconnect button (when connected) - Rectangle { - visible: netWidget.netState === "connected" - width: parent.width - height: 28 - color: disconnectMouse.containsMouse ? "#${c.base02}" : "transparent" - radius: 4 - - Text { - anchors.centerIn: parent - text: "Disconnect" - color: "#${c.base08}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - } - - MouseArea { - id: disconnectMouse - anchors.fill: parent - hoverEnabled: true - onClicked: { - netDisconnectProc.targetDevice = netWidget.netDevice; - netDisconnectProc.running = true; - netWidget.netState = "disconnected"; - netWidget.netConn = ""; - netWidget.netIcon = "\u{f05aa}"; - bar.closeAllDropdowns(); - netRefreshDelay.start(); - } - } - } - - // Separator - Rectangle { - width: parent.width - 20 - anchors.horizontalCenter: parent.horizontalCenter - height: 1 - color: "#${c.base03}" - } - - // Available networks header - Text { - text: "Available networks" - color: "#${c.base03}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - topPadding: 2 - } - - // Network list - Repeater { - model: netWidget.wifiNetworks - - Rectangle { - required property var modelData - width: 220 - height: 32 - color: netItemMouse.containsMouse ? "#${c.base02}" : "transparent" - radius: 4 - - Row { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.right: parent.right - anchors.rightMargin: 8 - spacing: 8 - - // Signal icon - Text { - text: { - let s = modelData.signal; - if (s >= 75) return "\u{f05a9}"; - if (s >= 50) return "\u{f05a9}"; - if (s >= 25) return "\u{f05a9}"; - return "\u{f05aa}"; - } - color: modelData.active ? "#${c.base0B}" : "#${c.base04}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - anchors.verticalCenter: parent.verticalCenter - } - - // SSID - Text { - text: modelData.ssid - color: modelData.active ? "#${c.base0B}" : "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - elide: Text.ElideRight - width: 140 - anchors.verticalCenter: parent.verticalCenter - } - - // Lock icon for secured networks - Text { - visible: modelData.security !== "" && modelData.security !== "--" - text: "\u{f0341}" - color: "#${c.base03}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - } - } - - MouseArea { - id: netItemMouse - anchors.fill: parent - hoverEnabled: true - onClicked: { - if (!modelData.active) { - wifiConnectProc.targetSsid = modelData.ssid; - wifiConnectProc.running = true; - netRefreshDelay.start(); - } - bar.closeAllDropdowns(); - } - } - } - } - } - } - - ${lib.optionalString isMacbook '' - // Battery dropdown - BarDropdown { - id: batteryDropdown - fullWidth: batteryDropdownCol.width + 24 - fullHeight: batteryDropdownCol.height + 16 - - Column { - id: batteryDropdownCol - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: 10 - width: 200 - spacing: 8 - - // Battery status - Row { - width: parent.width - spacing: 8 - - Text { - text: batteryWidget.batteryIcon - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 18 - anchors.verticalCenter: parent.verticalCenter - } - - Column { - anchors.verticalCenter: parent.verticalCenter - Text { - text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "") - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - } - Text { - text: batteryWidget.powerDraw.toFixed(1) + " W" - + (batteryWidget.timeRemaining !== "" ? " \u2022 " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "") - color: "#${c.base04}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - } - } - } - - // Separator - Rectangle { - width: parent.width - 10 - anchors.horizontalCenter: parent.horizontalCenter - height: 1 - color: "#${c.base03}" - } - - // Power profile label - Text { - text: "Power Profile" - color: "#${c.base03}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - } - - // Profile buttons - Row { - width: parent.width - spacing: 4 - - Repeater { - model: [ - { name: "power-saver", label: "\u{f0425}", tip: "Saver" }, - { name: "balanced", label: "\u{f0376}", tip: "Balanced" }, - { name: "performance", label: "\u{f0e0e}", tip: "Performance" } - ] - - Rectangle { - required property var modelData - width: (parent.width - 8) / 3 - height: 36 - radius: 6 - color: batteryWidget.powerProfile === modelData.name - ? "#${c.base02}" : profMouse.containsMouse - ? "#${c.base01}" : "transparent" - border.width: batteryWidget.powerProfile === modelData.name ? 1 : 0 - border.color: "#${c.base03}" - - Column { - anchors.centerIn: parent - spacing: 1 - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: modelData.label - color: batteryWidget.powerProfile === modelData.name - ? "#${c.base0D}" : "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 14 - } - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: modelData.tip - color: "#${c.base04}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 9 - } - } - - MouseArea { - id: profMouse - anchors.fill: parent - hoverEnabled: true - onClicked: { - setProfileProc.target = modelData.name; - setProfileProc.running = true; - batteryWidget.powerProfile = modelData.name; - } - } - } - } - } - } - } - ''} - - // Calendar popup - BarDropdown { - id: calPopup - dropdownX: bar.width / 2 - fullWidth: calCol.width + 32 - fullHeight: calCol.height + 24 - - Column { - id: calCol - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: 12 - spacing: 8 - opacity: calPopup.open ? 1.0 : 0.0 - - Behavior on opacity { - NumberAnimation { duration: 150; easing.type: Easing.OutCubic } - } - - // Date header - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: clockText.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM yyyy") - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 14 - font.weight: Font.Medium - } - - // Day headers - Row { - spacing: 0 - Repeater { - model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] - Text { - required property var modelData - width: 28 - horizontalAlignment: Text.AlignHCenter - text: modelData - color: "#${c.base03}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - } - } - } - - // Calendar grid - Grid { - columns: 7 - spacing: 0 - - Repeater { - id: calRepeater - model: 42 - - Rectangle { - required property int index - width: 28 - height: 24 - radius: 4 - color: { - let d = clockText.now; - let first = new Date(d.getFullYear(), d.getMonth(), 1); - let startDay = (first.getDay() + 6) % 7; - let dayNum = index - startDay + 1; - let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); - return (dayNum === d.getDate() && dayNum >= 1 && dayNum <= daysInMonth) - ? "#${c.base02}" : "transparent"; - } - - Text { - anchors.centerIn: parent - text: { - let d = clockText.now; - let first = new Date(d.getFullYear(), d.getMonth(), 1); - let startDay = (first.getDay() + 6) % 7; - let dayNum = parent.index - startDay + 1; - let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); - return (dayNum >= 1 && dayNum <= daysInMonth) ? dayNum.toString() : ""; - } - color: { - let d = clockText.now; - let first = new Date(d.getFullYear(), d.getMonth(), 1); - let startDay = (first.getDay() + 6) % 7; - let dayNum = parent.index - startDay + 1; - return (dayNum === d.getDate()) ? "#${c.base05}" : "#${c.base04}"; - } - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - } - } - } - } - - // Separator - Rectangle { - width: 7 * 28 - height: 1 - color: "#${c.base02}" - } - - // Notifications header - Row { - width: 7 * 28 - Text { - text: "Notifications" - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - } - Item { Layout.fillWidth: true; width: 10 } - Text { - anchors.right: parent.right - text: notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" - color: "#${c.base04}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - let notifs = notifServer.trackedNotifications.values; - for (let i = notifs.length - 1; i >= 0; i--) { - notifs[i].dismiss(); - } - } - } - } - } - - // Notification list - Column { - spacing: 4 - width: 7 * 28 - - Text { - visible: notifServer.trackedNotifications.values.length === 0 - text: "No notifications" - color: "#${c.base03}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - anchors.horizontalCenter: parent.horizontalCenter - } - - Repeater { - model: notifServer.trackedNotifications - - Rectangle { - id: notifItem - required property var modelData - width: 7 * 28 - height: notifCol.height + 12 - radius: 6 - color: "#${c.base01}" - - Column { - id: notifCol - anchors.left: parent.left - anchors.right: dismissBtn.left - anchors.top: parent.top - anchors.margins: 6 - spacing: 2 - - Text { - width: parent.width - text: notifItem.modelData.summary || notifItem.modelData.appName - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - font.weight: Font.Medium - elide: Text.ElideRight - } - - Text { - width: parent.width - text: notifItem.modelData.body || "" - color: "#${c.base04}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - elide: Text.ElideRight - maximumLineCount: 2 - wrapMode: Text.Wrap - visible: text !== "" - } - - // Action buttons - Row { - spacing: 4 - visible: notifItem.modelData.actions.length > 0 - Repeater { - model: notifItem.modelData.actions - Rectangle { - required property var modelData - width: actionText.width + 12 - height: actionText.height + 4 - radius: 4 - color: actionMa.containsMouse ? "#${c.base02}" : "#${c.base01}" - border.width: 1 - border.color: "#${c.base02}" - Text { - id: actionText - anchors.centerIn: parent - text: modelData.text - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - } - MouseArea { - id: actionMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: modelData.invoke() - } - } - } - } - } - - // Dismiss button - Text { - id: dismissBtn - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 6 - text: "\u{f0156}" - color: dismissMa.containsMouse ? "#${c.base05}" : "#${c.base03}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - MouseArea { - id: dismissMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: notifItem.modelData.dismiss() - } - } - } - } - } - } - } - } - } - } - - // Notification toast - Variants { - model: Quickshell.screens - - PopupWindow { - id: notifToast - required property var modelData - screen: modelData - property var currentNotif: null - property bool open: false - - Connections { - target: root - function onNotificationReceived() { - if (notifToast.modelData === Quickshell.screens[0]) { - notifToast.show(root.latestNotification); - } - } - } - - function show(notification) { - currentNotif = notification; - visible = true; - open = true; - _toastTimer.restart(); - } - - function dismiss() { - open = false; - _toastCloseDelay.start(); - } - - anchor.rect.x: (screen ? screen.width / 2 : 0) - 160 - anchor.rect.y: 30 - anchor.edges: Edges.Top | Edges.Left - visible: false - implicitWidth: 320 - implicitHeight: toastContent.height + 2 - color: "transparent" - - Timer { - id: _toastTimer - interval: 5000 - onTriggered: notifToast.dismiss() - } - - Timer { - id: _toastCloseDelay - interval: 230 - onTriggered: { notifToast.visible = false; notifToast.open = false; } - } - - HoverHandler { - onHoveredChanged: { - if (hovered) _toastTimer.stop(); - else _toastTimer.restart(); - } - } - - Rectangle { - id: toastContent - anchors.top: parent.top - anchors.topMargin: 2 - anchors.horizontalCenter: parent.horizontalCenter - width: 316 - height: toastCol.height + 16 - radius: 8 - color: "#E6${c.base00}" - clip: true - - transform: Translate { - y: notifToast.open ? 0 : -(toastContent.height + 10) - Behavior on y { - NumberAnimation { duration: 220; easing.type: Easing.OutCubic } - } - } - - opacity: notifToast.open ? 1.0 : 0.0 - Behavior on opacity { - NumberAnimation { duration: 200; easing.type: Easing.OutCubic } - } - - Column { - id: toastCol - anchors.left: parent.left - anchors.right: toastDismiss.left - anchors.top: parent.top - anchors.margins: 8 - spacing: 2 - - Text { - width: parent.width - text: notifToast.currentNotif ? (notifToast.currentNotif.summary || notifToast.currentNotif.appName) : "" - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - font.weight: Font.Medium - elide: Text.ElideRight - } - - Text { - width: parent.width - text: notifToast.currentNotif ? (notifToast.currentNotif.body || "") : "" - color: "#${c.base04}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - elide: Text.ElideRight - maximumLineCount: 3 - wrapMode: Text.Wrap - visible: text !== "" - } - - // Action buttons - Row { - spacing: 4 - visible: notifToast.currentNotif && notifToast.currentNotif.actions.length > 0 - Repeater { - model: notifToast.currentNotif ? notifToast.currentNotif.actions : [] - Rectangle { - required property var modelData - width: toastActionText.width + 12 - height: toastActionText.height + 6 - radius: 4 - color: toastActionMa.containsMouse ? "#${c.base02}" : "#${c.base01}" - border.width: 1 - border.color: "#${c.base02}" - Text { - id: toastActionText - anchors.centerIn: parent - text: modelData.text - color: "#${c.base05}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - } - MouseArea { - id: toastActionMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { modelData.invoke(); notifToast.dismiss(); } - } - } - } - } - } - - // Dismiss X - Text { - id: toastDismiss - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 8 - text: "\u{f0156}" - color: toastDismissMa.containsMouse ? "#${c.base05}" : "#${c.base03}" - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - MouseArea { - id: toastDismissMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { notifToast.currentNotif.dismiss(); notifToast.dismiss(); } - } - } - } - } - } - } + wifiConnectScript = pkgs.writeShellScript "wifi-connect" '' + ssid="$1" + ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" 2>/dev/null && exit 0 + pw=$(${pkgs.zenity}/bin/zenity --password --title="WiFi Password" 2>/dev/null) + [ -n "$pw" ] && ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" password "$pw" ''; + nmcli = "${pkgs.networkmanager}/bin/nmcli"; + powerprofilesctl = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl"; + in { + "quickshell/qmldir" = { + onChange = qsRestart; + text = '' + singleton Theme 1.0 Theme.qml + singleton Commands 1.0 Commands.qml + ''; + }; + + "quickshell/Theme.qml" = { + onChange = qsRestart; + text = '' + pragma Singleton + import QtQuick + + QtObject { + readonly property color base00: "#${c.base00}" + readonly property color base01: "#${c.base01}" + readonly property color base02: "#${c.base02}" + readonly property color base03: "#${c.base03}" + readonly property color base04: "#${c.base04}" + readonly property color base05: "#${c.base05}" + readonly property color base08: "#${c.base08}" + readonly property color base0A: "#${c.base0A}" + readonly property color base0B: "#${c.base0B}" + readonly property color base0C: "#${c.base0C}" + readonly property color base0D: "#${c.base0D}" + readonly property color barBg: "#D1${c.base00}" + readonly property color toastBg: "#E6${c.base00}" + } + ''; + }; + + "quickshell/Commands.qml" = { + onChange = qsRestart; + text = '' + pragma Singleton + import QtQuick + + QtObject { + readonly property string nmcli: "${nmcli}" + readonly property string wifiConnect: "${wifiConnectScript}" + readonly property string powerprofilesctl: "${powerprofilesctl}" + } + ''; + }; + + "quickshell/shell.qml" = { + onChange = qsRestart; + text = '' + //@ pragma UseQApplication + import Quickshell + import Quickshell.Services.Notifications + import QtQuick + + ShellRoot { + id: root + property var latestNotification: null + signal notificationReceived() + + NotificationServer { + id: notifServer + bodySupported: true + actionsSupported: true + imageSupported: true + persistenceSupported: true + keepOnReload: true + onNotification: (notification) => { + notification.tracked = true; + root.latestNotification = notification; + root.notificationReceived(); + } + } + + Variants { + model: Quickshell.screens + + Bar { + notifServer: notifServer + } + } + + Variants { + model: Quickshell.screens + + NotificationToast { + shellRoot: root + } + } + } + ''; + }; + + "quickshell/Bar.qml" = { + onChange = qsRestart; + text = '' + import Quickshell + import Quickshell.Hyprland + import Quickshell.Services.SystemTray + import Quickshell.Services.Notifications + import Quickshell.Widgets + import Quickshell.Io + import QtQuick + import QtQuick.Layouts + import Qt5Compat.GraphicalEffects + + PanelWindow { + id: bar + required property var modelData + required property NotificationServer notifServer + screen: modelData + + anchors { + top: true + left: true + right: true + } + + implicitHeight: 30 + color: Theme.barBg + + property var activeDropdown: null + function closeAllDropdowns() { + if (activeDropdown && activeDropdown.visible) { + activeDropdown.animateClose(); + } + activeDropdown = null; + } + function toggleDropdown(dd, setupFn) { + if (dd.visible && !dd.closing) { + dd.animateClose(); + activeDropdown = null; + } else { + if (activeDropdown && activeDropdown !== dd && activeDropdown.visible) { + activeDropdown.animateClose(); + } + if (setupFn) setupFn(); + if (dd.closing) { + dd.closing = false; + dd.open = true; + } else { + dd.visible = true; + } + activeDropdown = dd; + } + } + + // Left — workspaces + Row { + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Repeater { + model: Hyprland.workspaces + + Item { + required property var modelData + width: 28 + height: 30 + + Text { + anchors.centerIn: parent + text: modelData.name + color: modelData.focused ? Theme.base05 : Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + + Rectangle { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 8 + height: 2 + color: Theme.base05 + visible: modelData.focused + } + + MouseArea { + anchors.fill: parent + onClicked: modelData.activate() + } + } + } + } + + // Center — clock + Text { + id: clockText + anchors.centerIn: parent + property date now: new Date() + text: now.toLocaleTimeString(Qt.locale(), "HH:mm") + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: clockText.now = new Date() + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: bar.toggleDropdown(calPopup) + onEntered: { if (bar.activeDropdown && bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup); } + } + } + + // Right — network, battery, tray + Row { + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + + // Network status + Item { + id: netWidget + width: 16 + height: 30 + + property string netState: "disconnected" + property string netConn: "" + property string netType: "" + property string netIcon: "\u{f0b0}" + property var wifiNetworks: [] + property string netDevice: "" + + property string _pendingState: "disconnected" + property string _pendingConn: "" + property string _pendingType: "" + property string _pendingDevice: "" + property var _pendingNets: [] + + Timer { + interval: 5000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: netWidget.refreshNet() + } + + function refreshNet() { + netWidget._pendingState = "disconnected"; + netWidget._pendingConn = ""; + netWidget._pendingType = ""; + netProc.running = true; + } + + Process { + id: netProc + command: [Commands.nmcli, "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"] + stdout: SplitParser { + onRead: data => { + let fields = data.split(":"); + if (fields.length < 4) return; + let type = fields[1]; + let state = fields[2]; + let conn = fields[3]; + if (type !== "ethernet" && type !== "wifi") return; + if (type === "wifi") { + netWidget._pendingDevice = fields[0]; + } + if (state === "connected") { + netWidget._pendingState = "connected"; + netWidget._pendingConn = conn; + netWidget._pendingType = type; + } + } + } + onRunningChanged: { + if (!running) { + netWidget.netState = netWidget._pendingState; + netWidget.netConn = netWidget._pendingConn; + netWidget.netType = netWidget._pendingType.length > 0 ? netWidget._pendingType : netWidget.netType; + netWidget.netDevice = netWidget._pendingDevice.length > 0 ? netWidget._pendingDevice : netWidget.netDevice; + if (netWidget.netState === "connected") { + netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05a9}" : "\u{f0200}"; + } else { + netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05aa}" : "\u{f0201}"; + } + } + } + } + + Text { + anchors.centerIn: parent + text: netWidget.netIcon + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 14 + } + + Timer { + id: netRefreshDelay + interval: 2000 + onTriggered: netWidget.refreshNet() + } + + Process { + id: wifiScanProc + command: [Commands.nmcli, "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "device", "wifi", "list", "--rescan", "auto"] + stdout: SplitParser { + onRead: data => { + let fields = data.split(":"); + if (fields.length < 4 || fields[0] === "") return; + for (let i = 0; i < netWidget._pendingNets.length; i++) { + if (netWidget._pendingNets[i].ssid === fields[0]) return; + } + netWidget._pendingNets.push({ + ssid: fields[0], + signal: parseInt(fields[1]) || 0, + security: fields[2], + active: fields[3] === "*" + }); + } + } + onRunningChanged: { + if (!running) { + netWidget.wifiNetworks = netWidget._pendingNets; + netWidget._pendingNets = []; + } + } + } + + Process { + id: wifiConnectProc + property string targetSsid: "" + command: [Commands.wifiConnect, targetSsid] + } + + Process { + id: netDisconnectProc + property string targetDevice: "" + command: [Commands.nmcli, "device", "disconnect", targetDevice] + } + + function openNetDropdown() { + bar.toggleDropdown(netDropdown, function() { + wifiScanProc.running = true; + let pos = netWidget.mapToItem(bar.contentItem, netWidget.width / 2, 0); + netDropdown.dropdownX = pos.x; + }); + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: netWidget.openNetDropdown() + onEntered: { if (bar.activeDropdown && bar.activeDropdown !== netDropdown) netWidget.openNetDropdown(); } + } + } + + ${lib.optionalString isMacbook '' + // Battery + Item { + id: batteryWidget + width: batteryText.width + 4 + batteryIconText.width + height: 30 + + property int batteryLevel: 0 + property bool charging: false + property string batteryIcon: "\u{f008e}" + property real powerDraw: 0.0 + property real energyNow: 0.0 + property real energyFull: 0.0 + property string timeRemaining: "" + property string powerProfile: "balanced" + + function updateIcon() { + if (charging) { batteryIcon = "\u{f0084}"; return; } + if (batteryLevel >= 90) batteryIcon = "\u{f0079}"; + else if (batteryLevel >= 70) batteryIcon = "\u{f0082}"; + else if (batteryLevel >= 50) batteryIcon = "\u{f007f}"; + else if (batteryLevel >= 30) batteryIcon = "\u{f007c}"; + else if (batteryLevel >= 15) batteryIcon = "\u{f007a}"; + else batteryIcon = "\u{f008e}"; + } + + Timer { + interval: 5000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { batteryProc.running = true; profileProc.running = true; } + } + + Process { + id: batteryProc + command: ["sh", "-c", "cat /sys/class/power_supply/BAT0/capacity; cat /sys/class/power_supply/BAT0/status; cat /sys/class/power_supply/BAT0/power_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_full 2>/dev/null || echo 0"] + stdout: SplitParser { + property int lineNum: 0 + onRead: data => { + let trimmed = data.trim(); + let num = parseInt(trimmed); + lineNum++; + if (lineNum === 1) { + if (!isNaN(num)) batteryWidget.batteryLevel = num; + } else if (lineNum === 2) { + batteryWidget.charging = (trimmed === "Charging"); + } else if (lineNum === 3) { + if (!isNaN(num)) batteryWidget.powerDraw = num / 1000000.0; + } else if (lineNum === 4) { + if (!isNaN(num)) batteryWidget.energyNow = num / 1000000.0; + } else if (lineNum === 5) { + if (!isNaN(num)) batteryWidget.energyFull = num / 1000000.0; + lineNum = 0; + if (batteryWidget.powerDraw > 0.5) { + let hours; + if (batteryWidget.charging) { + hours = (batteryWidget.energyFull - batteryWidget.energyNow) / batteryWidget.powerDraw; + } else { + hours = batteryWidget.energyNow / batteryWidget.powerDraw; + } + let h = Math.floor(hours); + let m = Math.round((hours - h) * 60); + batteryWidget.timeRemaining = h + "h " + m + "m"; + } else { + batteryWidget.timeRemaining = ""; + } + } + batteryWidget.updateIcon(); + } + } + } + + Process { + id: profileProc + command: [Commands.powerprofilesctl, "get"] + stdout: SplitParser { + onRead: data => { + batteryWidget.powerProfile = data.trim(); + } + } + } + + Process { + id: setProfileProc + property string target: "balanced" + command: [Commands.powerprofilesctl, "set", target] + } + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Text { + id: batteryText + text: batteryWidget.batteryLevel + "%" + color: batteryWidget.batteryLevel <= 15 ? Theme.base08 + : batteryWidget.batteryLevel <= 30 ? Theme.base0A + : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + + Text { + id: batteryIconText + text: batteryWidget.batteryIcon + color: batteryWidget.batteryLevel <= 15 ? Theme.base08 + : batteryWidget.batteryLevel <= 30 ? Theme.base0A + : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 14 + } + } + + function openBatteryDropdown() { + bar.toggleDropdown(batteryDropdown, function() { + batteryProc.running = true; + profileProc.running = true; + let pos = batteryWidget.mapToItem(bar.contentItem, batteryWidget.width / 2, 0); + batteryDropdown.dropdownX = pos.x; + }); + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: batteryWidget.openBatteryDropdown() + onEntered: { if (bar.activeDropdown && bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown(); } + } + } + ''} + + // Tray icons + Row { + id: trayArea + spacing: 8 + anchors.verticalCenter: parent.verticalCenter + + Repeater { + model: SystemTray.items + + Item { + required property var modelData + width: 16 + height: 16 + + Image { + id: trayIcon + anchors.fill: parent + source: modelData.icon + sourceSize.width: 16 + sourceSize.height: 16 + smooth: true + mipmap: true + visible: false + } + + ColorOverlay { + anchors.fill: trayIcon + source: trayIcon + color: Theme.base05 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: { + if (bar.activeDropdown && modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) { + bar.toggleDropdown(contextMenu, function() { + let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); + contextMenu.dropdownX = pos.x; + contextMenu.trayItem = modelData; + menuOpener.menu = modelData.menu; + }); + } + } + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (event) => { + if (modelData.hasMenu) { + bar.toggleDropdown(contextMenu, function() { + let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); + contextMenu.dropdownX = pos.x; + contextMenu.trayItem = modelData; + menuOpener.menu = modelData.menu; + }); + } else { + modelData.activate(); + } + } + } + } + } + } + } + + // Reusable dropdown component + component BarDropdown: PopupWindow { + id: dropdown + property bool open: false + property bool closing: false + property real dropdownX: 0 + property real fullWidth: 200 + property real fullHeight: 200 + property int autoCloseMs: 1500 + default property alias content: dropdownContent.data + + function animateClose() { + if (!visible || closing) return; + closing = true; + open = false; + _autoClose.stop(); + _closeDelay.start(); + } + + anchor.window: bar + anchor.rect.x: dropdownX - (fullWidth + 16) / 2 + anchor.rect.y: bar.height + anchor.edges: Edges.Top | Edges.Left + anchor.gravity: Edges.Bottom | Edges.Right + anchor.adjustment: PopupAdjustment.Slide + visible: false + color: "transparent" + implicitWidth: fullWidth + 16 + implicitHeight: fullHeight + 4 + + onVisibleChanged: { + if (visible) { + closing = false; + open = true; + _autoClose.restart(); + } else { + open = false; + closing = false; + _autoClose.stop(); + } + } + + Timer { + id: _autoClose + interval: dropdown.autoCloseMs + onTriggered: bar.closeAllDropdowns() + } + + Timer { + id: _closeDelay + interval: 230 + onTriggered: { dropdown.visible = false; dropdown.closing = false; } + } + + HoverHandler { + onHoveredChanged: { + if (hovered) _autoClose.stop(); + else _autoClose.restart(); + } + } + + Item { + anchors.right: _dropdownRect.left + anchors.top: parent.top + width: 8 + height: Math.min(8, _dropdownRect.height) + clip: true + visible: _dropdownRect.height > 0 + Canvas { + anchors.top: parent.top + width: 8; height: 8 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, 8, 8); + ctx.fillStyle = Theme.barBg; + ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.closePath(); ctx.fill(); + } + } + } + + Item { + anchors.left: _dropdownRect.right + anchors.top: parent.top + width: 8 + height: Math.min(8, _dropdownRect.height) + clip: true + visible: _dropdownRect.height > 0 + Canvas { + anchors.top: parent.top + width: 8; height: 8 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, 8, 8); + ctx.fillStyle = Theme.barBg; + ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(8, 0); + ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); + ctx.closePath(); ctx.fill(); + } + } + } + + Rectangle { + id: _dropdownRect + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: dropdown.fullWidth + height: dropdown.open ? dropdown.fullHeight : 0 + color: Theme.barBg + radius: 8 + topLeftRadius: 0 + topRightRadius: 0 + clip: true + + Behavior on height { + NumberAnimation { duration: 220; easing.type: Easing.OutCubic } + } + + Item { + id: dropdownContent + anchors.fill: parent + } + } + } + + // Context menu + BarDropdown { + id: contextMenu + property var trayItem: null + fullWidth: menuItems.width + 16 + fullHeight: menuItems.height + 12 + + onVisibleChanged: { + if (!visible) menuOpener.menu = null; + } + + QsMenuOpener { + id: menuOpener + } + + Column { + id: menuItems + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 6 + width: 200 + + Repeater { + model: menuOpener.children + + Rectangle { + required property var modelData + width: 200 + height: modelData.isSeparator ? 9 : 28 + color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled + ? Theme.base02 : "transparent" + radius: modelData.isSeparator ? 0 : 4 + + Rectangle { + visible: modelData.isSeparator + anchors.centerIn: parent + width: parent.width - 20 + height: 1 + color: Theme.base03 + } + + RowLayout { + visible: !modelData.isSeparator + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Text { + Layout.fillWidth: true + text: modelData.text ?? "" + color: modelData.enabled ? Theme.base05 : Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + elide: Text.ElideRight + } + + Text { + visible: modelData.buttonType !== QsMenuButtonType.None + text: modelData.checkState === Qt.Checked ? "\u2713" : "" + color: Theme.base0D + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + } + } + + MouseArea { + id: itemMouse + anchors.fill: parent + hoverEnabled: true + enabled: !modelData.isSeparator && modelData.enabled + onClicked: { + modelData.triggered(); + bar.closeAllDropdowns(); + } + } + } + } + } + } + + // Network dropdown + BarDropdown { + id: netDropdown + fullWidth: netDropdownCol.width + 24 + fullHeight: netDropdownCol.height + 16 + + Column { + id: netDropdownCol + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 8 + width: 220 + spacing: 4 + + Text { + width: parent.width + text: netWidget.netState === "connected" + ? "\u{f05a9} " + netWidget.netConn + : "\u{f05aa} Not connected" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Rectangle { + visible: netWidget.netState === "connected" + width: parent.width + height: 28 + color: disconnectMouse.containsMouse ? Theme.base02 : "transparent" + radius: 4 + + Text { + anchors.centerIn: parent + text: "Disconnect" + color: Theme.base08 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + } + + MouseArea { + id: disconnectMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + netDisconnectProc.targetDevice = netWidget.netDevice; + netDisconnectProc.running = true; + netWidget.netState = "disconnected"; + netWidget.netConn = ""; + netWidget.netIcon = "\u{f05aa}"; + bar.closeAllDropdowns(); + netRefreshDelay.start(); + } + } + } + + Rectangle { + width: parent.width - 20 + anchors.horizontalCenter: parent.horizontalCenter + height: 1 + color: Theme.base03 + } + + Text { + text: "Available networks" + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + topPadding: 2 + } + + Repeater { + model: netWidget.wifiNetworks + + Rectangle { + required property var modelData + width: 220 + height: 32 + color: netItemMouse.containsMouse ? Theme.base02 : "transparent" + radius: 4 + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.right: parent.right + anchors.rightMargin: 8 + spacing: 8 + + Text { + text: { + let s = modelData.signal; + if (s >= 75) return "\u{f05a9}"; + if (s >= 50) return "\u{f05a9}"; + if (s >= 25) return "\u{f05a9}"; + return "\u{f05aa}"; + } + color: modelData.active ? Theme.base0B : Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: modelData.ssid + color: modelData.active ? Theme.base0B : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + elide: Text.ElideRight + width: 140 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + visible: modelData.security !== "" && modelData.security !== "--" + text: "\u{f0341}" + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: netItemMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + if (!modelData.active) { + wifiConnectProc.targetSsid = modelData.ssid; + wifiConnectProc.running = true; + netRefreshDelay.start(); + } + bar.closeAllDropdowns(); + } + } + } + } + } + } + + ${lib.optionalString isMacbook '' + // Battery dropdown + BarDropdown { + id: batteryDropdown + fullWidth: batteryDropdownCol.width + 24 + fullHeight: batteryDropdownCol.height + 16 + + Column { + id: batteryDropdownCol + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 10 + width: 200 + spacing: 8 + + Row { + width: parent.width + spacing: 8 + + Text { + text: batteryWidget.batteryIcon + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 18 + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + Text { + text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "") + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + } + Text { + text: batteryWidget.powerDraw.toFixed(1) + " W" + + (batteryWidget.timeRemaining !== "" ? " \u2022 " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "") + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + } + } + } + + Rectangle { + width: parent.width - 10 + anchors.horizontalCenter: parent.horizontalCenter + height: 1 + color: Theme.base03 + } + + Text { + text: "Power Profile" + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + } + + Row { + width: parent.width + spacing: 4 + + Repeater { + model: [ + { name: "power-saver", label: "\u{f0425}", tip: "Saver" }, + { name: "balanced", label: "\u{f0376}", tip: "Balanced" }, + { name: "performance", label: "\u{f0e0e}", tip: "Performance" } + ] + + Rectangle { + required property var modelData + width: (parent.width - 8) / 3 + height: 36 + radius: 6 + color: batteryWidget.powerProfile === modelData.name + ? Theme.base02 : profMouse.containsMouse + ? Theme.base01 : "transparent" + border.width: batteryWidget.powerProfile === modelData.name ? 1 : 0 + border.color: Theme.base03 + + Column { + anchors.centerIn: parent + spacing: 1 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.label + color: batteryWidget.powerProfile === modelData.name + ? Theme.base0D : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 14 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.tip + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 9 + } + } + + MouseArea { + id: profMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + setProfileProc.target = modelData.name; + setProfileProc.running = true; + batteryWidget.powerProfile = modelData.name; + } + } + } + } + } + } + } + ''} + + // Calendar popup + BarDropdown { + id: calPopup + dropdownX: bar.width / 2 + fullWidth: calCol.width + 32 + fullHeight: calCol.height + 24 + + Column { + id: calCol + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 12 + spacing: 8 + opacity: calPopup.open ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { duration: 150; easing.type: Easing.OutCubic } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: clockText.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM yyyy") + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 14 + font.weight: Font.Medium + } + + Row { + spacing: 0 + Repeater { + model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + Text { + required property var modelData + width: 28 + horizontalAlignment: Text.AlignHCenter + text: modelData + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + } + } + } + + Grid { + columns: 7 + spacing: 0 + + Repeater { + id: calRepeater + model: 42 + + Rectangle { + required property int index + width: 28 + height: 24 + radius: 4 + color: { + let d = clockText.now; + let first = new Date(d.getFullYear(), d.getMonth(), 1); + let startDay = (first.getDay() + 6) % 7; + let dayNum = index - startDay + 1; + let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + return (dayNum === d.getDate() && dayNum >= 1 && dayNum <= daysInMonth) + ? Theme.base02 : "transparent"; + } + + Text { + anchors.centerIn: parent + text: { + let d = clockText.now; + let first = new Date(d.getFullYear(), d.getMonth(), 1); + let startDay = (first.getDay() + 6) % 7; + let dayNum = parent.index - startDay + 1; + let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + return (dayNum >= 1 && dayNum <= daysInMonth) ? dayNum.toString() : ""; + } + color: { + let d = clockText.now; + let first = new Date(d.getFullYear(), d.getMonth(), 1); + let startDay = (first.getDay() + 6) % 7; + let dayNum = parent.index - startDay + 1; + return (dayNum === d.getDate()) ? Theme.base05 : Theme.base04; + } + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + } + } + } + } + + Rectangle { + width: 7 * 28 + height: 1 + color: Theme.base02 + } + + Row { + width: 7 * 28 + Text { + text: "Notifications" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + } + Item { Layout.fillWidth: true; width: 10 } + Text { + anchors.right: parent.right + text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + let notifs = bar.notifServer.trackedNotifications.values; + for (let i = notifs.length - 1; i >= 0; i--) { + notifs[i].dismiss(); + } + } + } + } + } + + Column { + spacing: 4 + width: 7 * 28 + + Text { + visible: bar.notifServer.trackedNotifications.values.length === 0 + text: "No notifications" + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + anchors.horizontalCenter: parent.horizontalCenter + } + + Repeater { + model: bar.notifServer.trackedNotifications + + Rectangle { + id: notifItem + required property var modelData + width: 7 * 28 + height: notifCol.height + 12 + radius: 6 + color: Theme.base01 + + Column { + id: notifCol + anchors.left: parent.left + anchors.right: dismissBtn.left + anchors.top: parent.top + anchors.margins: 6 + spacing: 2 + + Text { + width: parent.width + text: notifItem.modelData.summary || notifItem.modelData.appName + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: notifItem.modelData.body || "" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.Wrap + visible: text !== "" + } + + Row { + spacing: 4 + visible: notifItem.modelData.actions.length > 0 + Repeater { + model: notifItem.modelData.actions + Rectangle { + required property var modelData + width: actionText.width + 12 + height: actionText.height + 4 + radius: 4 + color: actionMa.containsMouse ? Theme.base02 : Theme.base01 + border.width: 1 + border.color: Theme.base02 + Text { + id: actionText + anchors.centerIn: parent + text: modelData.text + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + } + MouseArea { + id: actionMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: modelData.invoke() + } + } + } + } + } + + Text { + id: dismissBtn + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 6 + text: "\u{f0156}" + color: dismissMa.containsMouse ? Theme.base05 : Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + MouseArea { + id: dismissMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: notifItem.modelData.dismiss() + } + } + } + } + } + } + } + } + ''; + }; + + "quickshell/NotificationToast.qml" = { + onChange = qsRestart; + text = '' + import Quickshell + import QtQuick + + PopupWindow { + id: notifToast + required property var modelData + required property var shellRoot + screen: modelData + property var currentNotif: null + property bool open: false + + Connections { + target: shellRoot + function onNotificationReceived() { + if (notifToast.modelData === Quickshell.screens[0]) { + notifToast.show(shellRoot.latestNotification); + } + } + } + + function show(notification) { + currentNotif = notification; + visible = true; + open = true; + _toastTimer.restart(); + } + + function dismiss() { + open = false; + _toastCloseDelay.start(); + } + + anchor.rect.x: (screen ? screen.width / 2 : 0) - 160 + anchor.rect.y: 30 + anchor.edges: Edges.Top | Edges.Left + visible: false + implicitWidth: 320 + implicitHeight: toastContent.height + 2 + color: "transparent" + + Timer { + id: _toastTimer + interval: 5000 + onTriggered: notifToast.dismiss() + } + + Timer { + id: _toastCloseDelay + interval: 230 + onTriggered: { notifToast.visible = false; notifToast.open = false; } + } + + HoverHandler { + onHoveredChanged: { + if (hovered) _toastTimer.stop(); + else _toastTimer.restart(); + } + } + + Rectangle { + id: toastContent + anchors.top: parent.top + anchors.topMargin: 2 + anchors.horizontalCenter: parent.horizontalCenter + width: 316 + height: toastCol.height + 16 + radius: 8 + color: Theme.toastBg + clip: true + + transform: Translate { + y: notifToast.open ? 0 : -(toastContent.height + 10) + Behavior on y { + NumberAnimation { duration: 220; easing.type: Easing.OutCubic } + } + } + + opacity: notifToast.open ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.OutCubic } + } + + Column { + id: toastCol + anchors.left: parent.left + anchors.right: toastDismiss.left + anchors.top: parent.top + anchors.margins: 8 + spacing: 2 + + Text { + width: parent.width + text: notifToast.currentNotif ? (notifToast.currentNotif.summary || notifToast.currentNotif.appName) : "" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: notifToast.currentNotif ? (notifToast.currentNotif.body || "") : "" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + elide: Text.ElideRight + maximumLineCount: 3 + wrapMode: Text.Wrap + visible: text !== "" + } + + Row { + spacing: 4 + visible: notifToast.currentNotif && notifToast.currentNotif.actions.length > 0 + Repeater { + model: notifToast.currentNotif ? notifToast.currentNotif.actions : [] + Rectangle { + required property var modelData + width: toastActionText.width + 12 + height: toastActionText.height + 6 + radius: 4 + color: toastActionMa.containsMouse ? Theme.base02 : Theme.base01 + border.width: 1 + border.color: Theme.base02 + Text { + id: toastActionText + anchors.centerIn: parent + text: modelData.text + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + } + MouseArea { + id: toastActionMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { modelData.invoke(); notifToast.dismiss(); } + } + } + } + } + } + + Text { + id: toastDismiss + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + text: "\u{f0156}" + color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + MouseArea { + id: toastDismissMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { notifToast.currentNotif.dismiss(); notifToast.dismiss(); } + } + } + } + } + ''; + }; }; };