From 7bf997176e0a3d7f144d807bb5f1424db75a065e Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 11 Jun 2026 10:00:02 +0100 Subject: [PATCH] quickshell: split QML out of hyprland.nix Co-Authored-By: Claude Fable 5 --- settings/hyprland.nix | 1969 +------------------------------------- settings/quickshell.nix | 1987 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1988 insertions(+), 1968 deletions(-) create mode 100644 settings/quickshell.nix diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 720a678..0afef3a 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -55,9 +55,7 @@ in networkmanagerapplet pavucontrol polkit_gnome - quickshell anyrun - qt6.qt5compat zenity libcanberra-gtk3 ]; @@ -423,1972 +421,7 @@ in Install.WantedBy = [ "hyprland-session.target" ]; }; - systemd.user.services.quickshell = { - Unit = { - Description = "Quickshell desktop shell"; - PartOf = [ "graphical-session.target" ]; - After = [ "graphical-session.target" ]; - }; - Service = { - ExecStart = "${pkgs.quickshell}/bin/qs"; - Restart = "always"; - RestartSec = 2; - }; - Install.WantedBy = [ "hyprland-session.target" ]; - }; - - xdg.configFile = let - qsRestart = '' - ${pkgs.systemd}/bin/systemctl --user restart quickshell.service 2>/dev/null || true - ''; - 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 - Bar 1.0 Bar.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: "#B3${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}" - readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play" - } - ''; - }; - - "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 - shellRoot: root - } - } - } - ''; - }; - - "quickshell/Bar.qml" = { - onChange = qsRestart; - text = '' - import Quickshell - import Quickshell.Hyprland - import Quickshell.Wayland - import Quickshell.Services.SystemTray - import Quickshell.Services.Notifications - import Quickshell.Services.Pipewire - 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 - required property var shellRoot - screen: modelData - WlrLayershell.namespace: "quickshell-bar" - - anchors { - top: true - left: true - right: true - } - - implicitHeight: bar.screen.height - exclusiveZone: 30 - color: "transparent" - - mask: Region { - item: barBgRect - Region { - x: activeDropdown ? activeDropdown.x : 0 - y: activeDropdown ? activeDropdown.y : 0 - width: activeDropdown && activeDropdown.visible ? activeDropdown.width : 0 - height: activeDropdown && activeDropdown.visible ? activeDropdown.height : 0 - } - Region { - x: toastItem.visible ? toastItem.x : 0 - y: toastItem.visible ? toastItem.y : 0 - width: toastItem.visible ? toastItem.width : 0 - height: toastItem.visible ? toastItem.height : 0 - } - } - - Rectangle { - id: barBgRect - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: 30 - color: Theme.barBg - } - - // The "gap source" for the bar border — dropdown takes priority, then toast - property bool hasGap: (activeDropdown && activeDropdown.dropdownHeight > 0) - || (toastItem.visible && _toastRect.height > 0) - property real gapLeft: activeDropdown && activeDropdown.dropdownHeight > 0 - ? activeDropdown.x - : toastItem.visible && _toastRect.height > 0 - ? toastItem.x : 0 - property real gapRight: activeDropdown && activeDropdown.dropdownHeight > 0 - ? activeDropdown.x + activeDropdown.width - : toastItem.visible && _toastRect.height > 0 - ? toastItem.x + toastItem.width : 0 - property bool gapAlignRight: activeDropdown ? activeDropdown.alignRight : false - - // Bar bottom border — left segment (up to gap) - Rectangle { - id: barBorderLeft - x: 0; y: 30 - width: bar.hasGap ? bar.gapLeft : bar.width - height: 1 - color: Theme.base03 - } - - // Bar bottom border — right segment (after gap) - Rectangle { - id: barBorderRight - visible: bar.hasGap && !bar.gapAlignRight - x: bar.gapRight - y: 30 - width: bar.width - x - height: 1 - color: Theme.base03 - } - - property var activeDropdown: null - - function closeAllDropdowns() { - if (activeDropdown && activeDropdown.visible) { - activeDropdown.animateClose(); - } - } - function toggleDropdown(dd, setupFn) { - if (dd.visible && !dd.closing) { - dd.animateClose(); - } 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: barBgRect.verticalCenter - spacing: 0 - - Repeater { - model: Hyprland.workspaces - - Item { - required property var modelData - visible: modelData.id > 0 - width: visible ? 28 : 0 - 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.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: barBgRect.verticalCenter - 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) { - if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup); - else bar.activeDropdown.resetAutoClose(); - } - } - } - } - - // Right — network, battery, tray - Row { - anchors.right: parent.right - anchors.rightMargin: 8 - anchors.verticalCenter: barBgRect.verticalCenter - spacing: 10 - - // Volume - Item { - id: volWidget - width: volText.width - height: 30 - - property PwNode sink: Pipewire.defaultAudioSink - - PwObjectTracker { - objects: [volWidget.sink] - } - - property int vol: sink && sink.audio ? Math.round(sink.audio.volume * 100) : 0 - property bool muted: sink && sink.audio ? sink.audio.muted : false - property string volIcon: muted ? "\u{f0581}" - : vol > 66 ? "\u{f057e}" - : vol > 33 ? "\u{f0580}" - : vol > 0 ? "\u{f057f}" - : "\u{f0581}" - - function openVolDropdown() { - bar.toggleDropdown(volDropdown, function() { - let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0); - volDropdown.dropdownX = pos.x; - }); - } - - Text { - id: volText - anchors.verticalCenter: parent.verticalCenter - text: volWidget.volIcon + " " + volWidget.vol + "%" - color: volWidget.muted ? Theme.base03 : Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.MiddleButton - onClicked: (event) => { - if (event.button === Qt.MiddleButton) { - if (volWidget.sink && volWidget.sink.audio) - volWidget.sink.audio.muted = !volWidget.sink.audio.muted; - } else { - volWidget.openVolDropdown(); - } - } - onEntered: { - if (bar.activeDropdown) { - if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown(); - else bar.activeDropdown.resetAutoClose(); - } - } - } - } - - // 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) { - if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown(); - else bar.activeDropdown.resetAutoClose(); - } - } - } - } - - ${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) { - if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown(); - else bar.activeDropdown.resetAutoClose(); - } - } - } - } - ''} - - // Tray icons - Row { - id: trayArea - spacing: 8 - height: 30 - anchors.verticalCenter: parent.verticalCenter - - HoverHandler { - onHoveredChanged: { - if (hovered && bar.activeDropdown) bar.activeDropdown.resetAutoClose(); - } - } - - Repeater { - model: SystemTray.items - - Item { - required property var modelData - width: 24 - height: 30 - - Image { - id: trayIcon - anchors.centerIn: parent - width: 16 - height: 16 - 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) { - bar.activeDropdown.resetAutoClose(); - if (modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) { - if (bar.activeDropdown === contextMenu) { - // Same dropdown, just switch content - let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); - contextMenu.dropdownX = pos.x; - contextMenu.trayItem = modelData; - menuOpener.menu = modelData.menu; - contextMenu.resetAutoClose(); - } else { - 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: Item { - 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 - property bool alignRight: false - property real dropdownHeight: _dropdownRect.height - default property alias content: dropdownContent.data - - function animateClose() { - if (!visible || closing) return; - closing = true; - open = false; - _autoClose.stop(); - _closeDelay.start(); - } - - function resetAutoClose() { - if (visible && !closing) _autoClose.restart(); - } - - x: alignRight ? bar.width - width : Math.min( - bar.width - width, - Math.max(0, dropdownX - (fullWidth + 16) / 2) - ) - y: 30 - visible: false - width: fullWidth + (alignRight ? 8 : 16) - height: fullHeight + 4 + (alignRight ? 8 : 0) - - 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; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; } - } - - HoverHandler { - onHoveredChanged: { - if (hovered) _autoClose.stop(); - else _autoClose.restart(); - } - } - - // Left ear - Item { - anchors.right: _dropdownRect.left - anchors.top: parent.top - width: 8 - height: Math.min(8, _dropdownRect.height) - clip: true - visible: _dropdownRect.height >= 8 - 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(); - // Border stroke along the curve - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.stroke(); - } - } - } - - // Right ear (for centered dropdowns) - Item { - anchors.left: _dropdownRect.right - anchors.top: parent.top - width: 8 - height: Math.min(8, _dropdownRect.height) - clip: true - visible: _dropdownRect.height >= 8 && !dropdown.alignRight - 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(); - // Border stroke along the curve - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); - ctx.stroke(); - } - } - } - - Rectangle { - id: _dropdownRect - anchors.right: dropdown.alignRight ? parent.right : undefined - anchors.horizontalCenter: dropdown.alignRight ? undefined : parent.horizontalCenter - anchors.top: parent.top - width: dropdown.fullWidth - height: dropdown.open ? dropdown.fullHeight : 0 - color: Theme.barBg - radius: 8 - topLeftRadius: 0 - topRightRadius: 0 - bottomRightRadius: dropdown.alignRight ? 0 : 8 - clip: true - - // Border outline (sides + bottom with rounded corners) - Canvas { - id: _dropdownBorder - anchors.fill: parent - onPaint: { - var ctx = getContext("2d"); - var w = width, h = height, r = 8; - ctx.clearRect(0, 0, w, h); - if (h < 1) return; - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - // Start below the top ear, go down left side - ctx.moveTo(0.5, r); - ctx.lineTo(0.5, h - r); - // Bottom-left curve - ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true); - // Bottom edge - if (dropdown.alignRight) { - // Stop 8px before right edge — bottom-right ear continues - ctx.lineTo(w - r, h - 0.5); - } else { - ctx.lineTo(w - r - 0.5, h - 0.5); - // Bottom-right curve - ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true); - // Right side up (stop at ear height) - ctx.lineTo(w - 0.5, r); - } - ctx.stroke(); - } - // Repaint when size changes - onWidthChanged: requestPaint() - onHeightChanged: requestPaint() - } - - Behavior on height { - NumberAnimation { duration: 220; easing.type: Easing.OutCubic } - } - - Item { - id: dropdownContent - anchors.fill: parent - } - } - - // Bottom-right concave ear — connects dropdown bottom to right screen edge - Item { - visible: dropdown.alignRight && _dropdownRect.height >= 8 - anchors.right: _dropdownRect.right - anchors.top: _dropdownRect.bottom - width: 8 - height: Math.min(8, _dropdownRect.height) - clip: true - Canvas { - 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.fill(); - // Border stroke along the curve - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.stroke(); - } - } - } - } - - // Context menu - BarDropdown { - id: contextMenu - alignRight: true - property var trayItem: null - fullWidth: menuItems.width + 24 - fullHeight: menuItems.height + 16 - - onVisibleChanged: { - if (!visible) menuOpener.menu = null; - } - - QsMenuOpener { - id: menuOpener - } - - Column { - id: menuItems - anchors.centerIn: parent - 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(); - } - } - } - } - } - } - - // Volume dropdown - BarDropdown { - id: volDropdown - alignRight: true - fullWidth: volDropdownCol.width + 28 - fullHeight: volDropdownCol.height + 20 - autoCloseMs: 3000 - - Column { - id: volDropdownCol - anchors.centerIn: parent - width: 260 - spacing: 8 - - // Master volume - Text { - text: "\u{f057e} Master" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - } - - Row { - width: parent.width - spacing: 8 - - Rectangle { - id: masterSliderBg - width: parent.width - masterVolLabel.width - 8 - height: 20 - radius: 4 - color: Theme.base01 - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - width: volWidget.sink && volWidget.sink.audio - ? Math.min(1, volWidget.sink.audio.volume) * parent.width : 0 - height: parent.height - radius: 4 - color: volWidget.muted ? Theme.base03 : Theme.base0D - Behavior on width { NumberAnimation { duration: 80 } } - } - - MouseArea { - anchors.fill: parent - onPressed: (mouse) => setVolume(mouse) - onPositionChanged: (mouse) => { if (pressed) setVolume(mouse); } - function setVolume(mouse) { - if (!volWidget.sink || !volWidget.sink.audio) return; - let v = Math.max(0, Math.min(1, mouse.x / width)); - volWidget.sink.audio.volume = v; - } - } - } - - Text { - id: masterVolLabel - width: 36 - text: volWidget.vol + "%" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - } - } - - // Mute button - Rectangle { - width: parent.width - height: 28 - color: masterMuteMa.containsMouse ? Theme.base02 : "transparent" - radius: 4 - - Text { - anchors.centerIn: parent - text: volWidget.muted ? "\u{f0581} Unmute" : "\u{f057e} Mute" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { - id: masterMuteMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (volWidget.sink && volWidget.sink.audio) - volWidget.sink.audio.muted = !volWidget.sink.audio.muted; - } - } - } - - // Separator - Rectangle { - width: parent.width - 20 - anchors.horizontalCenter: parent.horizontalCenter - height: 1 - color: Theme.base02 - visible: appStreamsCol.childrenRect.height > 0 - } - - // App streams header - Text { - visible: appStreamsCol.childrenRect.height > 0 - text: "\u{f0641} Applications" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - } - - // Per-app streams - Column { - id: appStreamsCol - width: parent.width - spacing: 6 - - Repeater { - id: appStreamsRepeater - model: Pipewire.nodes - - Column { - required property var modelData - width: parent.width - spacing: 2 - visible: modelData.isStream && modelData.audio !== null - - PwObjectTracker { - objects: [modelData] - } - - Text { - text: modelData.properties["application.name"] || modelData.name || "Unknown" - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - elide: Text.ElideRight - width: parent.width - } - - Row { - width: parent.width - spacing: 8 - - Rectangle { - width: parent.width - appVolLabel.width - 8 - height: 16 - radius: 3 - color: Theme.base01 - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - width: modelData.audio - ? Math.min(1, modelData.audio.volume) * parent.width : 0 - height: parent.height - radius: 3 - color: modelData.audio && modelData.audio.muted - ? Theme.base03 : Theme.base0C - Behavior on width { NumberAnimation { duration: 80 } } - } - - MouseArea { - anchors.fill: parent - onPressed: (mouse) => setVol(mouse) - onPositionChanged: (mouse) => { if (pressed) setVol(mouse); } - function setVol(mouse) { - if (!modelData.audio) return; - let v = Math.max(0, Math.min(1, mouse.x / width)); - modelData.audio.volume = v; - } - } - } - - Text { - id: appVolLabel - width: 36 - text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%" - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - } - } - } - } - } - } - } - - // Network dropdown - BarDropdown { - id: netDropdown - alignRight: true - fullWidth: netDropdownCol.width + 28 - fullHeight: netDropdownCol.height + 20 - - Column { - id: netDropdownCol - anchors.centerIn: parent - 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 - alignRight: true - fullWidth: batteryDropdownCol.width + 28 - fullHeight: batteryDropdownCol.height + 20 - - Column { - id: batteryDropdownCol - anchors.centerIn: parent - 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 + 8 - fullHeight: calCol.height + 4 - - Column { - id: calCol - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - - spacing: 8 - opacity: calPopup.open ? 1.0 : 0.0 - - Behavior on opacity { - NumberAnimation { duration: 150; easing.type: Easing.OutCubic } - } - - Text { - id: calTitle - anchors.horizontalCenter: parent.horizontalCenter - text: clockText.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM yyyy") - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 16 - font.weight: Font.Medium - } - - Row { - id: weekdayRow - anchors.horizontalCenter: parent.horizontalCenter - spacing: 0 - Repeater { - model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] - Text { - required property var modelData - width: 32 - horizontalAlignment: Text.AlignHCenter - text: modelData - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - } - } - } - - Grid { - columns: 7 - spacing: 0 - - Repeater { - id: calRepeater - model: 42 - - Rectangle { - required property int index - width: 32 - height: 26 - 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.base03 : "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: 13 - } - } - } - } - - Rectangle { - width: 7 * 32 + 8 - height: 1 - color: Theme.base02 - anchors.horizontalCenter: parent.horizontalCenter - } - - Row { - width: 7 * 32 + 8 - anchors.horizontalCenter: parent.horizontalCenter - 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 * 32 + 8 - anchors.horizontalCenter: parent.horizontalCenter - - 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 * 32 + 8 - 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() - } - } - } - } - } - } - } - - // ── Notification Toast (only on primary screen) ── - Item { - id: toastItem - visible: false - property var currentNotif: null - property bool toastOpen: false - readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "vlc", "mpv"] - readonly property bool isPrimary: bar.screen === Quickshell.screens[0] - - x: Math.round(bar.width / 2 - width / 2) - y: 30 - width: _toastLeftEar.width + _toastRect.width + _toastRightEar.width - height: _toastRect.height + 4 - - Process { - id: notifSoundProc - command: [Commands.notifSound, "-i", "message"] - } - - Connections { - target: bar.shellRoot - function onNotificationReceived() { - if (toastItem.isPrimary) { - toastItem.showToast(bar.shellRoot.latestNotification); - } - } - } - - function showToast(notification) { - currentNotif = notification; - visible = true; - toastOpen = true; - _toastTimer.restart(); - if (!mutedApps.includes(notification.appName)) { - notifSoundProc.running = true; - } - } - - function dismiss() { - toastOpen = false; - _toastCloseDelay.start(); - } - - Timer { - id: _toastTimer - interval: 5000 - onTriggered: toastItem.dismiss() - } - - Timer { - id: _toastCloseDelay - interval: 230 - onTriggered: { toastItem.visible = false; toastItem.toastOpen = false; } - } - - HoverHandler { - onHoveredChanged: { - if (hovered) _toastTimer.stop(); - else _toastTimer.restart(); - } - } - - // Left inverse corner ear - Item { - id: _toastLeftEar - anchors.right: _toastRect.left - anchors.top: parent.top - width: 8 - height: Math.min(8, _toastRect.height) - clip: true - visible: _toastRect.height >= 8 - 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(); - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.stroke(); - } - } - } - - // Right inverse corner ear - Item { - id: _toastRightEar - anchors.left: _toastRect.right - anchors.top: parent.top - width: 8 - height: Math.min(8, _toastRect.height) - clip: true - visible: _toastRect.height >= 8 - 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(); - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); - ctx.stroke(); - } - } - } - - Rectangle { - id: _toastRect - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - width: 320 - height: toastItem.toastOpen ? toastCol.height + 16 : 0 - color: Theme.barBg - radius: 8 - topLeftRadius: 0 - topRightRadius: 0 - clip: true - - // Border outline (sides + bottom with rounded corners) - Canvas { - anchors.fill: parent - onPaint: { - var ctx = getContext("2d"); - var w = width, h = height, r = 8; - ctx.clearRect(0, 0, w, h); - if (h < 1) return; - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0.5, r); - ctx.lineTo(0.5, h - r); - ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true); - ctx.lineTo(w - r - 0.5, h - 0.5); - ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true); - ctx.lineTo(w - 0.5, r); - ctx.stroke(); - } - onWidthChanged: requestPaint() - onHeightChanged: requestPaint() - } - - Behavior on height { - NumberAnimation { duration: 220; 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: toastItem.currentNotif ? (toastItem.currentNotif.summary || toastItem.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: toastItem.currentNotif ? (toastItem.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: toastItem.currentNotif && toastItem.currentNotif.actions.length > 0 - Repeater { - model: toastItem.currentNotif ? toastItem.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(); toastItem.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: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); } - } - } - } - } - } - ''; - }; - + xdg.configFile = { # anyrun config — written manually since HM 26.05 has no anyrun module. "anyrun/config.ron".text = '' Config( diff --git a/settings/quickshell.nix b/settings/quickshell.nix new file mode 100644 index 0000000..59e97cf --- /dev/null +++ b/settings/quickshell.nix @@ -0,0 +1,1987 @@ +# settings/quickshell.nix — Quickshell desktop shell (bar, notifications, QML), +# split out of settings/hyprland.nix. The hyprland-side blur layer rule for +# the "quickshell-bar" namespace still lives there. +{ config, pkgs, lib, ... }: +let + isMacbook = config.networking.hostName == "FredOS-Macbook"; +in +{ + config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) { + environment.systemPackages = with pkgs; [ + quickshell + qt6.qt5compat # Qt5Compat.GraphicalEffects in Bar.qml + ]; + + home-manager.users.fred = { config, lib, pkgs, ... }: + let + c = config.lib.stylix.colors; + in { + systemd.user.services.quickshell = { + Unit = { + Description = "Quickshell desktop shell"; + PartOf = [ "graphical-session.target" ]; + After = [ "graphical-session.target" ]; + }; + Service = { + ExecStart = "${pkgs.quickshell}/bin/qs"; + Restart = "always"; + RestartSec = 2; + }; + Install.WantedBy = [ "hyprland-session.target" ]; + }; + + xdg.configFile = let + qsRestart = '' + ${pkgs.systemd}/bin/systemctl --user restart quickshell.service 2>/dev/null || true + ''; + 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 + Bar 1.0 Bar.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: "#B3${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}" + readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play" + } + ''; + }; + + "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 + shellRoot: root + } + } + } + ''; + }; + + "quickshell/Bar.qml" = { + onChange = qsRestart; + text = '' + import Quickshell + import Quickshell.Hyprland + import Quickshell.Wayland + import Quickshell.Services.SystemTray + import Quickshell.Services.Notifications + import Quickshell.Services.Pipewire + 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 + required property var shellRoot + screen: modelData + WlrLayershell.namespace: "quickshell-bar" + + anchors { + top: true + left: true + right: true + } + + implicitHeight: bar.screen.height + exclusiveZone: 30 + color: "transparent" + + mask: Region { + item: barBgRect + Region { + x: activeDropdown ? activeDropdown.x : 0 + y: activeDropdown ? activeDropdown.y : 0 + width: activeDropdown && activeDropdown.visible ? activeDropdown.width : 0 + height: activeDropdown && activeDropdown.visible ? activeDropdown.height : 0 + } + Region { + x: toastItem.visible ? toastItem.x : 0 + y: toastItem.visible ? toastItem.y : 0 + width: toastItem.visible ? toastItem.width : 0 + height: toastItem.visible ? toastItem.height : 0 + } + } + + Rectangle { + id: barBgRect + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 30 + color: Theme.barBg + } + + // The "gap source" for the bar border — dropdown takes priority, then toast + property bool hasGap: (activeDropdown && activeDropdown.dropdownHeight > 0) + || (toastItem.visible && _toastRect.height > 0) + property real gapLeft: activeDropdown && activeDropdown.dropdownHeight > 0 + ? activeDropdown.x + : toastItem.visible && _toastRect.height > 0 + ? toastItem.x : 0 + property real gapRight: activeDropdown && activeDropdown.dropdownHeight > 0 + ? activeDropdown.x + activeDropdown.width + : toastItem.visible && _toastRect.height > 0 + ? toastItem.x + toastItem.width : 0 + property bool gapAlignRight: activeDropdown ? activeDropdown.alignRight : false + + // Bar bottom border — left segment (up to gap) + Rectangle { + id: barBorderLeft + x: 0; y: 30 + width: bar.hasGap ? bar.gapLeft : bar.width + height: 1 + color: Theme.base03 + } + + // Bar bottom border — right segment (after gap) + Rectangle { + id: barBorderRight + visible: bar.hasGap && !bar.gapAlignRight + x: bar.gapRight + y: 30 + width: bar.width - x + height: 1 + color: Theme.base03 + } + + property var activeDropdown: null + + function closeAllDropdowns() { + if (activeDropdown && activeDropdown.visible) { + activeDropdown.animateClose(); + } + } + function toggleDropdown(dd, setupFn) { + if (dd.visible && !dd.closing) { + dd.animateClose(); + } 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: barBgRect.verticalCenter + spacing: 0 + + Repeater { + model: Hyprland.workspaces + + Item { + required property var modelData + visible: modelData.id > 0 + width: visible ? 28 : 0 + 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.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: barBgRect.verticalCenter + 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) { + if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup); + else bar.activeDropdown.resetAutoClose(); + } + } + } + } + + // Right — network, battery, tray + Row { + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: barBgRect.verticalCenter + spacing: 10 + + // Volume + Item { + id: volWidget + width: volText.width + height: 30 + + property PwNode sink: Pipewire.defaultAudioSink + + PwObjectTracker { + objects: [volWidget.sink] + } + + property int vol: sink && sink.audio ? Math.round(sink.audio.volume * 100) : 0 + property bool muted: sink && sink.audio ? sink.audio.muted : false + property string volIcon: muted ? "\u{f0581}" + : vol > 66 ? "\u{f057e}" + : vol > 33 ? "\u{f0580}" + : vol > 0 ? "\u{f057f}" + : "\u{f0581}" + + function openVolDropdown() { + bar.toggleDropdown(volDropdown, function() { + let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0); + volDropdown.dropdownX = pos.x; + }); + } + + Text { + id: volText + anchors.verticalCenter: parent.verticalCenter + text: volWidget.volIcon + " " + volWidget.vol + "%" + color: volWidget.muted ? Theme.base03 : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + onClicked: (event) => { + if (event.button === Qt.MiddleButton) { + if (volWidget.sink && volWidget.sink.audio) + volWidget.sink.audio.muted = !volWidget.sink.audio.muted; + } else { + volWidget.openVolDropdown(); + } + } + onEntered: { + if (bar.activeDropdown) { + if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown(); + else bar.activeDropdown.resetAutoClose(); + } + } + } + } + + // 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) { + if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown(); + else bar.activeDropdown.resetAutoClose(); + } + } + } + } + + ${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) { + if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown(); + else bar.activeDropdown.resetAutoClose(); + } + } + } + } + ''} + + // Tray icons + Row { + id: trayArea + spacing: 8 + height: 30 + anchors.verticalCenter: parent.verticalCenter + + HoverHandler { + onHoveredChanged: { + if (hovered && bar.activeDropdown) bar.activeDropdown.resetAutoClose(); + } + } + + Repeater { + model: SystemTray.items + + Item { + required property var modelData + width: 24 + height: 30 + + Image { + id: trayIcon + anchors.centerIn: parent + width: 16 + height: 16 + 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) { + bar.activeDropdown.resetAutoClose(); + if (modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) { + if (bar.activeDropdown === contextMenu) { + // Same dropdown, just switch content + let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); + contextMenu.dropdownX = pos.x; + contextMenu.trayItem = modelData; + menuOpener.menu = modelData.menu; + contextMenu.resetAutoClose(); + } else { + 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: Item { + 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 + property bool alignRight: false + property real dropdownHeight: _dropdownRect.height + default property alias content: dropdownContent.data + + function animateClose() { + if (!visible || closing) return; + closing = true; + open = false; + _autoClose.stop(); + _closeDelay.start(); + } + + function resetAutoClose() { + if (visible && !closing) _autoClose.restart(); + } + + x: alignRight ? bar.width - width : Math.min( + bar.width - width, + Math.max(0, dropdownX - (fullWidth + 16) / 2) + ) + y: 30 + visible: false + width: fullWidth + (alignRight ? 8 : 16) + height: fullHeight + 4 + (alignRight ? 8 : 0) + + 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; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; } + } + + HoverHandler { + onHoveredChanged: { + if (hovered) _autoClose.stop(); + else _autoClose.restart(); + } + } + + // Left ear + Item { + anchors.right: _dropdownRect.left + anchors.top: parent.top + width: 8 + height: Math.min(8, _dropdownRect.height) + clip: true + visible: _dropdownRect.height >= 8 + 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(); + // Border stroke along the curve + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.stroke(); + } + } + } + + // Right ear (for centered dropdowns) + Item { + anchors.left: _dropdownRect.right + anchors.top: parent.top + width: 8 + height: Math.min(8, _dropdownRect.height) + clip: true + visible: _dropdownRect.height >= 8 && !dropdown.alignRight + 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(); + // Border stroke along the curve + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); + ctx.stroke(); + } + } + } + + Rectangle { + id: _dropdownRect + anchors.right: dropdown.alignRight ? parent.right : undefined + anchors.horizontalCenter: dropdown.alignRight ? undefined : parent.horizontalCenter + anchors.top: parent.top + width: dropdown.fullWidth + height: dropdown.open ? dropdown.fullHeight : 0 + color: Theme.barBg + radius: 8 + topLeftRadius: 0 + topRightRadius: 0 + bottomRightRadius: dropdown.alignRight ? 0 : 8 + clip: true + + // Border outline (sides + bottom with rounded corners) + Canvas { + id: _dropdownBorder + anchors.fill: parent + onPaint: { + var ctx = getContext("2d"); + var w = width, h = height, r = 8; + ctx.clearRect(0, 0, w, h); + if (h < 1) return; + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + // Start below the top ear, go down left side + ctx.moveTo(0.5, r); + ctx.lineTo(0.5, h - r); + // Bottom-left curve + ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true); + // Bottom edge + if (dropdown.alignRight) { + // Stop 8px before right edge — bottom-right ear continues + ctx.lineTo(w - r, h - 0.5); + } else { + ctx.lineTo(w - r - 0.5, h - 0.5); + // Bottom-right curve + ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true); + // Right side up (stop at ear height) + ctx.lineTo(w - 0.5, r); + } + ctx.stroke(); + } + // Repaint when size changes + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + } + + Behavior on height { + NumberAnimation { duration: 220; easing.type: Easing.OutCubic } + } + + Item { + id: dropdownContent + anchors.fill: parent + } + } + + // Bottom-right concave ear — connects dropdown bottom to right screen edge + Item { + visible: dropdown.alignRight && _dropdownRect.height >= 8 + anchors.right: _dropdownRect.right + anchors.top: _dropdownRect.bottom + width: 8 + height: Math.min(8, _dropdownRect.height) + clip: true + Canvas { + 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.fill(); + // Border stroke along the curve + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.stroke(); + } + } + } + } + + // Context menu + BarDropdown { + id: contextMenu + alignRight: true + property var trayItem: null + fullWidth: menuItems.width + 24 + fullHeight: menuItems.height + 16 + + onVisibleChanged: { + if (!visible) menuOpener.menu = null; + } + + QsMenuOpener { + id: menuOpener + } + + Column { + id: menuItems + anchors.centerIn: parent + 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(); + } + } + } + } + } + } + + // Volume dropdown + BarDropdown { + id: volDropdown + alignRight: true + fullWidth: volDropdownCol.width + 28 + fullHeight: volDropdownCol.height + 20 + autoCloseMs: 3000 + + Column { + id: volDropdownCol + anchors.centerIn: parent + width: 260 + spacing: 8 + + // Master volume + Text { + text: "\u{f057e} Master" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + } + + Row { + width: parent.width + spacing: 8 + + Rectangle { + id: masterSliderBg + width: parent.width - masterVolLabel.width - 8 + height: 20 + radius: 4 + color: Theme.base01 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: volWidget.sink && volWidget.sink.audio + ? Math.min(1, volWidget.sink.audio.volume) * parent.width : 0 + height: parent.height + radius: 4 + color: volWidget.muted ? Theme.base03 : Theme.base0D + Behavior on width { NumberAnimation { duration: 80 } } + } + + MouseArea { + anchors.fill: parent + onPressed: (mouse) => setVolume(mouse) + onPositionChanged: (mouse) => { if (pressed) setVolume(mouse); } + function setVolume(mouse) { + if (!volWidget.sink || !volWidget.sink.audio) return; + let v = Math.max(0, Math.min(1, mouse.x / width)); + volWidget.sink.audio.volume = v; + } + } + } + + Text { + id: masterVolLabel + width: 36 + text: volWidget.vol + "%" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + // Mute button + Rectangle { + width: parent.width + height: 28 + color: masterMuteMa.containsMouse ? Theme.base02 : "transparent" + radius: 4 + + Text { + anchors.centerIn: parent + text: volWidget.muted ? "\u{f0581} Unmute" : "\u{f057e} Mute" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + } + MouseArea { + id: masterMuteMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (volWidget.sink && volWidget.sink.audio) + volWidget.sink.audio.muted = !volWidget.sink.audio.muted; + } + } + } + + // Separator + Rectangle { + width: parent.width - 20 + anchors.horizontalCenter: parent.horizontalCenter + height: 1 + color: Theme.base02 + visible: appStreamsCol.childrenRect.height > 0 + } + + // App streams header + Text { + visible: appStreamsCol.childrenRect.height > 0 + text: "\u{f0641} Applications" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + } + + // Per-app streams + Column { + id: appStreamsCol + width: parent.width + spacing: 6 + + Repeater { + id: appStreamsRepeater + model: Pipewire.nodes + + Column { + required property var modelData + width: parent.width + spacing: 2 + visible: modelData.isStream && modelData.audio !== null + + PwObjectTracker { + objects: [modelData] + } + + Text { + text: modelData.properties["application.name"] || modelData.name || "Unknown" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width + } + + Row { + width: parent.width + spacing: 8 + + Rectangle { + width: parent.width - appVolLabel.width - 8 + height: 16 + radius: 3 + color: Theme.base01 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: modelData.audio + ? Math.min(1, modelData.audio.volume) * parent.width : 0 + height: parent.height + radius: 3 + color: modelData.audio && modelData.audio.muted + ? Theme.base03 : Theme.base0C + Behavior on width { NumberAnimation { duration: 80 } } + } + + MouseArea { + anchors.fill: parent + onPressed: (mouse) => setVol(mouse) + onPositionChanged: (mouse) => { if (pressed) setVol(mouse); } + function setVol(mouse) { + if (!modelData.audio) return; + let v = Math.max(0, Math.min(1, mouse.x / width)); + modelData.audio.volume = v; + } + } + } + + Text { + id: appVolLabel + width: 36 + text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + } + + // Network dropdown + BarDropdown { + id: netDropdown + alignRight: true + fullWidth: netDropdownCol.width + 28 + fullHeight: netDropdownCol.height + 20 + + Column { + id: netDropdownCol + anchors.centerIn: parent + 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 + alignRight: true + fullWidth: batteryDropdownCol.width + 28 + fullHeight: batteryDropdownCol.height + 20 + + Column { + id: batteryDropdownCol + anchors.centerIn: parent + 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 + 8 + fullHeight: calCol.height + 4 + + Column { + id: calCol + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + + spacing: 8 + opacity: calPopup.open ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { duration: 150; easing.type: Easing.OutCubic } + } + + Text { + id: calTitle + anchors.horizontalCenter: parent.horizontalCenter + text: clockText.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM yyyy") + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 16 + font.weight: Font.Medium + } + + Row { + id: weekdayRow + anchors.horizontalCenter: parent.horizontalCenter + spacing: 0 + Repeater { + model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + Text { + required property var modelData + width: 32 + horizontalAlignment: Text.AlignHCenter + text: modelData + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + } + } + + Grid { + columns: 7 + spacing: 0 + + Repeater { + id: calRepeater + model: 42 + + Rectangle { + required property int index + width: 32 + height: 26 + 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.base03 : "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: 13 + } + } + } + } + + Rectangle { + width: 7 * 32 + 8 + height: 1 + color: Theme.base02 + anchors.horizontalCenter: parent.horizontalCenter + } + + Row { + width: 7 * 32 + 8 + anchors.horizontalCenter: parent.horizontalCenter + 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 * 32 + 8 + anchors.horizontalCenter: parent.horizontalCenter + + 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 * 32 + 8 + 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() + } + } + } + } + } + } + } + + // ── Notification Toast (only on primary screen) ── + Item { + id: toastItem + visible: false + property var currentNotif: null + property bool toastOpen: false + readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "vlc", "mpv"] + readonly property bool isPrimary: bar.screen === Quickshell.screens[0] + + x: Math.round(bar.width / 2 - width / 2) + y: 30 + width: _toastLeftEar.width + _toastRect.width + _toastRightEar.width + height: _toastRect.height + 4 + + Process { + id: notifSoundProc + command: [Commands.notifSound, "-i", "message"] + } + + Connections { + target: bar.shellRoot + function onNotificationReceived() { + if (toastItem.isPrimary) { + toastItem.showToast(bar.shellRoot.latestNotification); + } + } + } + + function showToast(notification) { + currentNotif = notification; + visible = true; + toastOpen = true; + _toastTimer.restart(); + if (!mutedApps.includes(notification.appName)) { + notifSoundProc.running = true; + } + } + + function dismiss() { + toastOpen = false; + _toastCloseDelay.start(); + } + + Timer { + id: _toastTimer + interval: 5000 + onTriggered: toastItem.dismiss() + } + + Timer { + id: _toastCloseDelay + interval: 230 + onTriggered: { toastItem.visible = false; toastItem.toastOpen = false; } + } + + HoverHandler { + onHoveredChanged: { + if (hovered) _toastTimer.stop(); + else _toastTimer.restart(); + } + } + + // Left inverse corner ear + Item { + id: _toastLeftEar + anchors.right: _toastRect.left + anchors.top: parent.top + width: 8 + height: Math.min(8, _toastRect.height) + clip: true + visible: _toastRect.height >= 8 + 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(); + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.stroke(); + } + } + } + + // Right inverse corner ear + Item { + id: _toastRightEar + anchors.left: _toastRect.right + anchors.top: parent.top + width: 8 + height: Math.min(8, _toastRect.height) + clip: true + visible: _toastRect.height >= 8 + 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(); + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); + ctx.stroke(); + } + } + } + + Rectangle { + id: _toastRect + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: 320 + height: toastItem.toastOpen ? toastCol.height + 16 : 0 + color: Theme.barBg + radius: 8 + topLeftRadius: 0 + topRightRadius: 0 + clip: true + + // Border outline (sides + bottom with rounded corners) + Canvas { + anchors.fill: parent + onPaint: { + var ctx = getContext("2d"); + var w = width, h = height, r = 8; + ctx.clearRect(0, 0, w, h); + if (h < 1) return; + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0.5, r); + ctx.lineTo(0.5, h - r); + ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true); + ctx.lineTo(w - r - 0.5, h - 0.5); + ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true); + ctx.lineTo(w - 0.5, r); + ctx.stroke(); + } + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + } + + Behavior on height { + NumberAnimation { duration: 220; 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: toastItem.currentNotif ? (toastItem.currentNotif.summary || toastItem.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: toastItem.currentNotif ? (toastItem.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: toastItem.currentNotif && toastItem.currentNotif.actions.length > 0 + Repeater { + model: toastItem.currentNotif ? toastItem.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(); toastItem.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: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); } + } + } + } + } + } + ''; + }; + }; + }; + }; +}