# 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 ]; # Icon font for the shell (ligature-based: text "volume_up" renders the # icon) — same font caelestia uses; nerd-font glyphs stay for terminals. fonts.packages = [ pkgs.material-symbols ]; home-manager.users.fred = { config, lib, pkgs, osConfig, ... }: 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 # Soft-reload quickshell in place: the process (and its DBus services — # tray host, notification daemon) stays alive, so Electron apps with # tray icons (vesktop) don't crash like they do on a hard restart. # Falls back to a unit restart if the IPC socket isn't up. qsRestart = '' ${pkgs.quickshell}/bin/qs ipc call shell reload 2>/dev/null || ${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"; # Follow stylix's monospace choice so a font swap propagates to the bar monoFont = osConfig.stylix.fonts.monospace.name; # Shell chrome fragment shader: the bar, screen frame, dropdown panel # and toast are one signed-distance field merged with a circular # smooth-min (caelestia-style liquid junctions); the 2px border is the # distance band just inside the surface, so it follows every fillet. # Qt 6 requires shaders precompiled to .qsb — done here at build time. chromeFragSrc = pkgs.writeText "shell-chrome.frag" '' #version 440 layout(location = 0) in vec2 qt_TexCoord0; layout(location = 0) out vec4 fragColor; layout(std140, binding = 0) uniform buf { mat4 qt_Matrix; float qt_Opacity; vec4 cutout; // cx, cy, hw, hh — rounded inner screen cutout vec4 panel; // cx, cy, hw, hh — dropdown panel (hw <= 0: none) vec4 toast; // cx, cy, hw, hh — toast (hw <= 0: none) vec4 session; // cx, cy, hw, hh — session menu (hw <= 0: none) vec4 launcher; // cx, cy, hw, hh — bottom launcher (hw <= 0: none) vec4 fillColor; // straight (non-premultiplied) rgba vec4 borderColor; vec2 res; float cutoutR; float panelR; float meltK; float borderW; }; float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float r) { vec2 d = abs(p - center) - halfSize + vec2(r); return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r; } // Circular smooth min: the blend fillet is a true circular arc of // radius k tangent to both surfaces. float smin(float a, float b, float k) { return max(k, min(a, b)) - length(max(vec2(k) - vec2(a, b), vec2(0.0))); } void main() { vec2 p = qt_TexCoord0 * res; // Shell = bar band + frame band = everything outside the cutout float d = -sdRoundedBox(p, cutout.xy, cutout.zw, cutoutR); if (panel.z > 0.5) d = smin(d, sdRoundedBox(p, panel.xy, panel.zw, panelR), meltK); if (toast.z > 0.5) d = smin(d, sdRoundedBox(p, toast.xy, toast.zw, panelR), meltK); if (session.z > 0.5) d = smin(d, sdRoundedBox(p, session.xy, session.zw, panelR), meltK); if (launcher.z > 0.5) d = smin(d, sdRoundedBox(p, launcher.xy, launcher.zw, panelR), meltK); float fw = fwidth(d); // 1 inside the union, 0 outside (antialiased) float edge = 1.0 - smoothstep(-fw, fw, d); // 1 deeper than the border band, 0 within it float inner = 1.0 - smoothstep(-borderW - fw, -borderW + fw, d); vec4 c = mix(borderColor, fillColor, inner); fragColor = vec4(c.rgb * c.a, c.a) * edge * qt_Opacity; } ''; chromeShader = pkgs.runCommand "shell-chrome.frag.qsb" { nativeBuildInputs = [ pkgs.qt6.qtshadertools ]; } '' qsb --glsl "300 es,330" --hlsl 50 --msl 12 -o $out ${chromeFragSrc} ''; # 7-day forecast JSON from Open-Meteo (no API key). Location is # auto-detected by IP via ipinfo.io, falling back to London. weatherFetchScript = pkgs.writeShellScript "weather-fetch" '' loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true) case "$loc" in *,*) lat=''${loc%,*}; lon=''${loc#*,} ;; *) lat=51.51; lon=-0.13 ;; esac ${pkgs.curl}/bin/curl -sf --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7" ''; 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 base06: "#${c.base06}" readonly property color base07: "#${c.base07}" 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}" readonly property string fontFamily: "${monoFont}" // Ligature-based icon font: text "volume_up" renders the icon readonly property string iconFont: "Material Symbols Rounded" // Matches hyprland general.border_size (col.inactive_border = base03) readonly property int borderWidth: 2 // Screen frame band; sits inside hyprland's gaps_out (12) readonly property int frameWidth: 6 // ── Layout / metric tokens (referenced, not hardcoded) ── // Bar band height. Drives widget heights, dropdown y-origin and // the shader cutout geometry — change here, not in ~10 places. readonly property int barHeight: 30 readonly property int radius: 8 // cards, panels, dropdowns readonly property int radiusSmall: 6 // workspace dots, pill buttons readonly property int radiusTiny: 4 // hover rows, action chips readonly property int cardPad: 8 // card inner padding (inset = 2×) // ── Animation duration tokens (ms) ── readonly property int animMorph: 280 // panel grow / slide (OutExpo) readonly property int animContent: 200 // content fade / highlight move readonly property int animFade: 120 // hover colour transitions } ''; }; "quickshell/Commands.qml" = { onChange = qsRestart; text = '' pragma Singleton import QtQuick QtObject { readonly property string nmcli: "${nmcli}" readonly property string wifiConnect: "${wifiConnectScript}" readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play" readonly property string hyprlock: "${pkgs.hyprlock}/bin/hyprlock" readonly property string systemctl: "${pkgs.systemd}/bin/systemctl" readonly property string weatherFetch: "${weatherFetchScript}" } ''; }; "quickshell/shell.qml" = { onChange = qsRestart; text = '' //@ pragma UseQApplication import Quickshell import Quickshell.Io import Quickshell.Services.Notifications import QtQuick ShellRoot { id: root property var latestNotification: null property var mainBar: null signal notificationReceived() // Bound in hyprland.nix: Super+R → launcher (bottom of the // bar window), Super+L → session menu (right edge). IpcHandler { target: "launcher" function toggle(): void { if (root.mainBar) root.mainBar.toggleLauncher(); } function powermenu(): void { if (root.mainBar) root.mainBar.toggleSession(); } } // Soft reload, used by the nix onChange hook — keeps the // process and its DBus services (tray host) alive. IpcHandler { target: "shell" function reload(): void { Quickshell.reload(false); } } 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.Services.UPower import Quickshell.Services.Mpris import Quickshell.Widgets import Quickshell.Io import QtQuick import Qt5Compat.GraphicalEffects PanelWindow { id: bar required property var modelData required property NotificationServer notifServer required property var shellRoot screen: modelData WlrLayershell.namespace: "quickshell-bar" // OnDemand + HyprlandFocusGrab is the working combination // (caelestia's): the grab redirects focus to this window and // OnDemand lets the layer surface accept it. Exclusive fights // the grab — it self-clears and instantly closes the panel. WlrLayershell.keyboardFocus: sessionMenu.open || launcherPanel.open ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None anchors { top: true left: true right: true } implicitHeight: bar.screen.height exclusiveZone: Theme.barHeight 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 } Region { x: sessionMenu.visible ? sessionMenu.x : 0 y: sessionMenu.visible ? sessionMenu.y : 0 width: sessionMenu.visible ? sessionMenu.width : 0 height: sessionMenu.visible ? sessionMenu.height : 0 } Region { x: launcherPanel.visible ? launcherPanel.x : 0 y: launcherPanel.visible ? launcherPanel.y : 0 width: launcherPanel.visible ? launcherPanel.width : 0 height: launcherPanel.visible ? launcherPanel.height : 0 } } Item { id: barBgRect anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right height: Theme.barHeight } // Register the primary bar so shell.qml's IPC handler can // reach the session menu. Component.onCompleted: { if (bar.screen === Quickshell.screens[0]) bar.shellRoot.mainBar = bar; } function toggleSession() { sessionMenu.toggle(); } function toggleLauncher() { launcherPanel.toggle(); } // ── Shared base text types: default the shell's two fonts so // no widget repeats `font.family`. Size/colour/weight are // overridable per use (these are just the common defaults). component SText: Text { color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 13 } component SIcon: Text { color: Theme.base05 font.family: Theme.iconFont font.pixelSize: 16 } // ── Card: the rounded base01 section surface used by every // dropdown. Children flow into a padded auto-height column, // so callers just set `width` and drop content in. component Card: Rectangle { default property alias cardData: _cardCol.data property alias cardSpacing: _cardCol.spacing radius: Theme.radius color: Theme.base01 implicitHeight: _cardCol.height + 2 * Theme.cardPad Column { id: _cardCol anchors.top: parent.top anchors.topMargin: Theme.cardPad anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 2 * Theme.cardPad spacing: 8 } } // ── PillSlider: slim rounded track + fill with a full-height // invisible hit area. `value` is 0..1; `moved(v)` fires on drag. component PillSlider: Item { property real value: 0 property color fillColor: Theme.base0D property real trackH: 6 signal moved(real v) height: 20 Rectangle { anchors.verticalCenter: parent.verticalCenter width: parent.width height: parent.trackH radius: parent.trackH / 2 color: Theme.base02 } Rectangle { anchors.verticalCenter: parent.verticalCenter width: parent.value > 0 ? Math.max(parent.trackH, parent.value * parent.width) : 0 height: parent.trackH radius: parent.trackH / 2 color: parent.fillColor Behavior on width { NumberAnimation { duration: 80 } } } MouseArea { anchors.fill: parent function set(mouse) { parent.moved(Math.max(0, Math.min(1, mouse.x / width))); } onPressed: (mouse) => set(mouse) onPositionChanged: (mouse) => { if (pressed) set(mouse); } } } // ── NotifContent: summary + body + action chips for one // notification, shared by the calendar list and the toast. // Callers supply the container, dismiss button and sizes. component NotifContent: Column { id: _nc property var notif property int summarySize: 12 property int bodySize: 11 property int bodyLines: 3 property color chipBg: Theme.base01 property color chipBgHover: Theme.base02 property color chipBorder: Theme.base02 signal actionInvoked() spacing: 2 SText { width: parent.width text: _nc.notif ? (_nc.notif.summary || _nc.notif.appName) : "" font.pixelSize: _nc.summarySize font.weight: Font.Medium elide: Text.ElideRight } SText { width: parent.width text: _nc.notif ? (_nc.notif.body || "") : "" color: Theme.base04 font.pixelSize: _nc.bodySize elide: Text.ElideRight maximumLineCount: _nc.bodyLines wrapMode: Text.Wrap visible: text !== "" } Row { spacing: 4 visible: _nc.notif && _nc.notif.actions.length > 0 Repeater { model: _nc.notif ? _nc.notif.actions : [] Rectangle { required property var modelData width: _at.width + 12 height: _at.height + 6 radius: Theme.radiusTiny color: _ama.containsMouse ? _nc.chipBgHover : _nc.chipBg Behavior on color { ColorAnimation { duration: Theme.animFade } } border.width: 1 border.color: _nc.chipBorder SText { id: _at; anchors.centerIn: parent; text: modelData.text; font.pixelSize: 10 } MouseArea { id: _ama anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { modelData.invoke(); _nc.actionInvoked(); } } } } } } // ── Shell chrome: bar, frame, panel and toast rendered as // ONE signed-distance field (caelestia-style). Surfaces merge // via circular smooth-min, and the 2px border is the distance // band just inside the boundary — borders flow through every // junction fillet by construction, so all of the previous // ears / border-gaps / melt geometry lives in the math now. ShaderEffect { anchors.fill: parent readonly property real panelLeft: chrome.x + 8 readonly property real panelRight: chrome.x + chrome.width + (chrome.flushRight ? 4 : -8) // The panel/toast centre-y uses (barHeight − 4): the // surface overlaps 4px up into the bar so they melt. readonly property real surfTopY: Theme.barHeight - 4 property vector4d cutout: Qt.vector4d( bar.width / 2, (Theme.barHeight + bar.height - Theme.frameWidth) / 2, bar.width / 2 - Theme.frameWidth, (bar.height - Theme.frameWidth - Theme.barHeight) / 2) property vector4d panel: chrome.visible ? Qt.vector4d((panelLeft + panelRight) / 2, surfTopY + chrome.height / 2, (panelRight - panelLeft) / 2, 4 + chrome.height / 2) : Qt.vector4d(0, 0, 0, 0) property vector4d toast: toastItem.visible && _toastRect.height > 0.5 ? Qt.vector4d(toastItem.x + 8 + _toastRect.width / 2, surfTopY + _toastRect.height / 2, _toastRect.width / 2, 4 + _toastRect.height / 2) : Qt.vector4d(0, 0, 0, 0) readonly property real sessRight: bar.width - Theme.frameWidth + 4 property vector4d session: sessionMenu.visible ? Qt.vector4d((sessionMenu.x + sessRight) / 2, sessionMenu.y + sessionMenu.height / 2, (sessRight - sessionMenu.x) / 2, sessionMenu.height / 2) : Qt.vector4d(0, 0, 0, 0) readonly property real launchBot: bar.height - Theme.frameWidth + 4 property vector4d launcher: launcherPanel.visible ? Qt.vector4d(launcherPanel.x + launcherPanel.width / 2, (launcherPanel.y + launchBot) / 2, launcherPanel.width / 2, (launchBot - launcherPanel.y) / 2) : Qt.vector4d(0, 0, 0, 0) property vector4d fillColor: Qt.vector4d(Theme.barBg.r, Theme.barBg.g, Theme.barBg.b, Theme.barBg.a) property vector4d borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1) property vector2d res: Qt.vector2d(width, height) property real cutoutR: Theme.radius property real panelR: Theme.radius property real meltK: 12 property real borderW: Theme.borderWidth fragmentShader: "file://${chromeShader}" } // ── Session menu: icon-only power controls morphing out of // the right frame column at screen centre (Super+L). Keyboard: // arrows/Tab move the selection, Enter activates, Esc closes. Item { id: sessionMenu property bool open: false property int selIdx: 0 // Both axes animate so the panel expands from a small // point on the column (like the top dropdowns' stub seed) // instead of rolling out at full height. property real openW: open ? 64 : 0 property real openH: open ? sessionCard.height + 24 : 36 Behavior on openW { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } Behavior on openH { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } readonly property var actions: [ { icon: "lock", danger: false, act: "lock" }, { icon: "logout", danger: false, act: "logout" }, { icon: "restart_alt", danger: true, act: "reboot" }, { icon: "power_settings_new", danger: true, act: "poweroff" } ] function activate(act) { open = false; if (act === "lock") Quickshell.execDetached([Commands.hyprlock]); else if (act === "logout") Hyprland.dispatch("hl.dsp.exit()"); else if (act === "reboot") Quickshell.execDetached([Commands.systemctl, "reboot"]); else Quickshell.execDetached([Commands.systemctl, "poweroff"]); } function toggle() { open = !open; if (open) { selIdx = 0; forceActiveFocus(); _sessionAutoClose.restart(); } } x: bar.width - Theme.frameWidth - openW y: Math.round((bar.height - height) / 2) width: openW height: openH visible: openW > 0.5 focus: open Keys.onEscapePressed: open = false Keys.onUpPressed: { selIdx = (selIdx + actions.length - 1) % actions.length; _sessionAutoClose.restart(); } Keys.onDownPressed: { selIdx = (selIdx + 1) % actions.length; _sessionAutoClose.restart(); } Keys.onTabPressed: { selIdx = (selIdx + 1) % actions.length; _sessionAutoClose.restart(); } Keys.onReturnPressed: activate(actions[selIdx].act) Keys.onEnterPressed: activate(actions[selIdx].act) Timer { id: _sessionAutoClose interval: 2500 onTriggered: sessionMenu.open = false } HoverHandler { onHoveredChanged: { if (hovered) _sessionAutoClose.stop(); else if (sessionMenu.open) _sessionAutoClose.restart(); } } // Content pinned to the column edge, revealed by the grow Item { anchors.fill: parent clip: true opacity: sessionMenu.open ? 1 : 0 Behavior on opacity { NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic } } // Card backing, matching the other dropdowns Rectangle { id: sessionCard anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right anchors.rightMargin: 8 width: 48 height: sessionCol.height + 8 radius: Theme.radius color: Theme.base01 // Sliding selection pill — same tech as the power // profile selector; glides between the buttons. Rectangle { width: 40 height: 40 radius: Theme.radius color: Theme.base02 border.width: 1 border.color: Theme.base03 x: sessionCol.x y: sessionCol.y + sessionMenu.selIdx * 44 Behavior on y { NumberAnimation { duration: 250; easing.type: Easing.OutExpo } } } Column { id: sessionCol anchors.centerIn: parent spacing: 4 Repeater { model: sessionMenu.actions Item { id: sessBtn required property var modelData required property int index readonly property bool selected: sessionMenu.selIdx === index width: 40 height: 40 SIcon { anchors.centerIn: parent text: sessBtn.modelData.icon // Calendar palette: base05 icons; red only when // a destructive action is the armed selection color: sessBtn.selected && sessBtn.modelData.danger ? Theme.base08 : Theme.base05 Behavior on color { ColorAnimation { duration: Theme.animFade } } font.pixelSize: 20 font.weight: 600 font.variableAxes: { "FILL": sessBtn.selected ? 1.0 : 0.0 } } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onEntered: sessionMenu.selIdx = sessBtn.index onClicked: sessionMenu.activate(sessBtn.modelData.act) } } } } } } } // ── Launcher: rises out of the bottom frame edge (Super+R). // Lives in the SDF field, so it melts into the frame and its // height morphs live as results filter. Results sit above the // search box; selection uses an animated ListView highlight. Item { id: launcherPanel property bool open: false readonly property real panelW: 420 property real targetH: 36 + launcherList.contentHeight + (launcherList.count > 0 ? 8 : 0) + 24 // Both axes animate: expands from a small point on the // bottom edge (like the top dropdowns' stub seed). property real openH: open ? targetH : 0 property real openW: open ? panelW : 80 Behavior on openH { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } Behavior on openW { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } x: Math.round((bar.width - openW) / 2) y: bar.height - Theme.frameWidth - openH width: openW height: openH visible: openH > 0.5 function toggle() { open = !open; if (open) { searchInput.text = ""; launcherList.currentIndex = 0; searchInput.forceActiveFocus(); } } function activate(item) { if (!item) return; item.execute(); open = false; } function score(name, extra, q) { let n = name.toLowerCase(); if (n.startsWith(q)) return 5; if (n.includes(" " + q)) return 4; if (n.includes(q)) return 3; if (extra && extra.toLowerCase().includes(q)) return 2; // Fuzzy: q as an in-order subsequence of n (vktop → // vesktop); fewer skipped characters scores higher. let qi = 0, gaps = 0, last = -1; for (let i = 0; i < n.length && qi < q.length; i++) { if (n[i] === q[qi]) { if (last >= 0) gaps += i - last - 1; last = i; qi++; } } if (qi === q.length) return 1 / (1 + gaps); return 0; } property var entries: { let q = searchInput.text.toLowerCase().trim(); let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay); if (q === "") { apps.sort((a, b) => a.name.localeCompare(b.name)); return apps.slice(0, 8); } let scored = []; for (let i = 0; i < apps.length; i++) { let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q); if (s > 0) scored.push({ app: apps[i], s: s }); } scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name)); return scored.slice(0, 8).map(x => x.app); } // Content anchored to the bottom so the grow reveals upward Item { anchors.fill: parent clip: true opacity: launcherPanel.open ? 1 : 0 Behavior on opacity { NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic } } Column { anchors.bottom: parent.bottom anchors.bottomMargin: 12 anchors.horizontalCenter: parent.horizontalCenter width: launcherPanel.panelW - 24 spacing: 8 ListView { id: launcherList width: parent.width height: contentHeight interactive: false model: launcherPanel.entries highlight: Rectangle { radius: Theme.radiusSmall color: Theme.base02 } highlightMoveDuration: 200 highlightMoveVelocity: -1 highlightResizeDuration: 0 delegate: Item { required property var modelData required property int index width: launcherList.width height: 32 Row { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 10 spacing: 10 Image { visible: source != "" anchors.verticalCenter: parent.verticalCenter width: 18 height: 18 sourceSize.width: 18 sourceSize.height: 18 source: Quickshell.iconPath(modelData.icon, true) } SText { anchors.verticalCenter: parent.verticalCenter text: modelData.name color: Theme.base05 font.pixelSize: 13 elide: Text.ElideRight width: 330 } } MouseArea { anchors.fill: parent hoverEnabled: true onEntered: launcherList.currentIndex = index onClicked: launcherPanel.activate(modelData) } } } Rectangle { width: parent.width height: 36 radius: Theme.radiusSmall color: Theme.base01 SIcon { id: searchIcon anchors.left: parent.left anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter text: "search" color: Theme.base04 font.pixelSize: 16 } TextInput { id: searchInput anchors.left: searchIcon.right anchors.leftMargin: 8 anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 13 clip: true onTextChanged: launcherList.currentIndex = 0 Keys.onEscapePressed: launcherPanel.open = false Keys.onUpPressed: launcherList.currentIndex = Math.max(0, launcherList.currentIndex - 1) Keys.onDownPressed: launcherList.currentIndex = Math.min(launcherPanel.entries.length - 1, launcherList.currentIndex + 1) Keys.onTabPressed: launcherList.currentIndex = (launcherList.currentIndex + 1) % Math.max(1, launcherPanel.entries.length) Keys.onReturnPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex]) Keys.onEnterPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex]) } SText { anchors.left: searchIcon.right anchors.leftMargin: 8 anchors.verticalCenter: parent.verticalCenter visible: searchInput.text === "" text: "Search" color: Theme.base03 font.pixelSize: 13 } } } } } // Click-outside dismissal for the keyboard-grabbing panels HyprlandFocusGrab { active: sessionMenu.open || launcherPanel.open windows: [bar] onCleared: { sessionMenu.open = false; launcherPanel.open = false; } } 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 (setupFn) setupFn(); // Opening from fully closed: seed the chrome as a // small stub on the widget so the panel grows out of // it (reviving mid-close morphs back instead). if (!activeDropdown && chrome.height < 0.5) { chrome.seedFromButton(dd); } // Retarget the chrome before closing the previous // dropdown so it morphs instead of dipping closed. const prev = activeDropdown; activeDropdown = dd; if (prev && prev !== dd && prev.visible) { prev.animateClose(); } if (dd.closing) { dd.revive(); } else { dd.visible = true; } } } // Left — workspace dots: accent pill for the focused // workspace, dim dots otherwise. All colours from Theme // (stylix); the pill matches hyprland's active border accent. Row { anchors.left: parent.left // Corner symmetry: the dots sit 12px from the screen's // top edge (centered in the 30px bar), so the first dot's // VISIBLE edge sits 12px from the left edge too. Cells // pad their dots by 3px, hence the -3. anchors.leftMargin: 12 - 3 anchors.verticalCenter: barBgRect.verticalCenter spacing: 4 Repeater { model: Hyprland.workspaces Item { id: wsItem required property var modelData visible: modelData.id > 0 width: visible ? dot.width + 6 : 0 height: Theme.barHeight Rectangle { id: dot anchors.centerIn: parent width: wsItem.modelData.focused ? 18 : 6 height: 6 radius: 3 color: wsItem.modelData.focused ? Theme.base0D : wsMa.containsMouse ? Theme.base04 : Theme.base03 Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutExpo } } Behavior on color { ColorAnimation { duration: Theme.animFade } } } MouseArea { id: wsMa anchors.fill: parent hoverEnabled: true onClicked: wsItem.modelData.activate() } } } } // Center — clock. SystemClock ticks on the minute boundary // instead of a 1 Hz Timer; the calendar reads clockText.now too. SystemClock { id: sysClock precision: SystemClock.Minutes } SText { id: clockText anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: barBgRect.verticalCenter property date now: sysClock.date text: now.toLocaleTimeString(Qt.locale(), "HH:mm") color: Theme.base05 font.pixelSize: 13 font.weight: Font.Medium MouseArea { anchors.fill: parent hoverEnabled: true onClicked: bar.toggleDropdown(calPopup, function() { calPopup.resetView(); }) onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup, function() { calPopup.resetView(); }); else bar.activeDropdown.resetAutoClose(); } } } } // Right — network, battery, tray Row { anchors.right: parent.right // Corner symmetry like the dots: last tray icon's VISIBLE // edge 12px from the right screen edge; tray cells pad // their 16px icons by 4px, hence the -4. The extra -2 // optically compensates for transparent padding baked // into typical tray icon artwork. anchors.rightMargin: 12 - 4 - 2 anchors.verticalCenter: barBgRect.verticalCenter spacing: 10 // Volume Item { id: volWidget width: volRow.width height: Theme.barHeight 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 ? "volume_off" : vol > 66 ? "volume_up" : vol > 33 ? "volume_down" : vol > 0 ? "volume_mute" : "volume_off" function openVolDropdown() { bar.toggleDropdown(volDropdown, function() { let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0); volDropdown.dropdownX = pos.x; }); } Row { id: volRow anchors.verticalCenter: parent.verticalCenter spacing: 3 SIcon { anchors.verticalCenter: parent.verticalCenter text: volWidget.volIcon color: volWidget.muted ? Theme.base03 : Theme.base05 font.pixelSize: 16 } SText { anchors.verticalCenter: parent.verticalCenter text: volWidget.vol + "%" color: volWidget.muted ? Theme.base03 : Theme.base05 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: Theme.barHeight property string netState: "disconnected" property string netConn: "" property string netType: "" property string netIcon: "wifi_off" property var wifiNetworks: [] property string netDevice: "" property string _pendingState: "disconnected" property string _pendingConn: "" property string _pendingType: "" property string _pendingDevice: "" property var _pendingNets: [] // Event-driven: `nmcli monitor` prints a line on every // NetworkManager event; debounce bursts into one refresh. Process { id: netMonitor command: [Commands.nmcli, "monitor"] running: true stdout: SplitParser { onRead: data => netRefreshDebounce.restart() } onRunningChanged: if (!running) netMonitorRestart.start() } Timer { id: netRefreshDebounce interval: 500 onTriggered: netWidget.refreshNet() } Timer { id: netMonitorRestart interval: 5000 onTriggered: netMonitor.running = true } // Slow fallback poll in case the monitor dies quietly Timer { interval: 60000 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" ? "wifi" : "lan"; } else { netWidget.netIcon = netWidget.netType === "wifi" ? "wifi_off" : "settings_ethernet"; } } } } SIcon { anchors.centerIn: parent text: netWidget.netIcon color: Theme.base05 font.pixelSize: 16 } 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: Theme.barHeight // Live DBus-driven properties from the UPower service — // no polling, no /sys parsing, no subprocess spawns. property var dev: UPower.displayDevice property int batteryLevel: dev && dev.ready ? Math.round(dev.percentage * 100) : 0 property bool charging: dev ? dev.state === UPowerDeviceState.Charging : false property real powerDraw: dev ? Math.abs(dev.changeRate) : 0.0 property string timeRemaining: { if (!dev) return ""; let secs = charging ? dev.timeToFull : dev.timeToEmpty; if (!secs || secs <= 0) return ""; let h = Math.floor(secs / 3600); let m = Math.round((secs % 3600) / 60); return h + "h " + m + "m"; } property string powerProfile: PowerProfiles.profile === PowerProfile.PowerSaver ? "power-saver" : PowerProfiles.profile === PowerProfile.Performance ? "performance" : "balanced" property string batteryIcon: charging ? "battery_charging_full" : batteryLevel >= 90 ? "battery_full" : batteryLevel >= 70 ? "battery_6_bar" : batteryLevel >= 50 ? "battery_5_bar" : batteryLevel >= 30 ? "battery_3_bar" : batteryLevel >= 15 ? "battery_2_bar" : "battery_alert" Row { anchors.verticalCenter: parent.verticalCenter spacing: 4 // Explicit vertical centering: Rows top-align by // default, and the icon font's taller line metrics // would push the text off the shared baseline. SText { id: batteryText anchors.verticalCenter: parent.verticalCenter text: batteryWidget.batteryLevel + "%" color: batteryWidget.batteryLevel <= 15 ? Theme.base08 : batteryWidget.batteryLevel <= 30 ? Theme.base0A : Theme.base05 font.pixelSize: 13 } SIcon { id: batteryIconText anchors.verticalCenter: parent.verticalCenter text: batteryWidget.batteryIcon color: batteryWidget.batteryLevel <= 15 ? Theme.base08 : batteryWidget.batteryLevel <= 30 ? Theme.base0A : Theme.base05 font.pixelSize: 16 } } function openBatteryDropdown() { bar.toggleDropdown(batteryDropdown, function() { 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: Theme.barHeight 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: Theme.barHeight 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(); } } } } } } } // Dropdown container — content, sizing and autoclose only. // The background, border and ears are drawn once by the shared // `chrome` panel below, which morphs between dropdowns. 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 // Flush-right dropdowns merge into the screen frame's // right column instead of centering on their widget. property bool alignRight: false property real dropdownHeight: open ? fullHeight : 0 default property alias content: dropdownContent.data function animateClose() { if (!visible || closing) return; closing = true; open = false; // Collapse the chrome immediately so the content fade // and the panel animation run together (when switching // dropdowns, toggleDropdown retargets activeDropdown // first, so this doesn't fire and the chrome morphs). // The panel also shrinks back toward its widget. if (bar.activeDropdown === dropdown) { bar.activeDropdown = null; chrome.shrinkToButton(dropdown); } _autoClose.stop(); _closeDelay.start(); } function resetAutoClose() { if (visible && !closing) _autoClose.restart(); } // Reopen a dropdown that's mid-close: the pending hide // timer must be cancelled, otherwise it fires later and // closes the revived dropdown (and the whole chrome). function revive() { _closeDelay.stop(); closing = false; open = true; _autoClose.restart(); } x: alignRight ? bar.width - Theme.frameWidth - width : Math.round(Math.min(bar.width - Theme.frameWidth - width, Math.max(Theme.frameWidth, dropdownX - width / 2))) y: Theme.barHeight width: fullWidth + (alignRight ? 8 : 16) height: fullHeight + 4 visible: false 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: 300 onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; } } HoverHandler { onHoveredChanged: { if (hovered) _autoClose.stop(); else _autoClose.restart(); } } // Content is clipped to the chrome's ANIMATED geometry — // revealed as the panel slides/grows over it and wiped as // the panel leaves, instead of popping in place. The inner // item counter-offsets so content stays put while the clip // window moves across it. Item { id: _dropdownRect x: (chrome.x + 8) - dropdown.x y: 0 width: Math.max(0, chrome.width - (chrome.flushRight ? 8 : 16)) height: Math.min(dropdown.fullHeight, chrome.height) clip: true opacity: dropdown.open ? 1 : 0 Behavior on opacity { NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic } } Item { id: dropdownContent x: 8 - _dropdownRect.x width: dropdown.fullWidth height: dropdown.fullHeight } } } // The shared morphing panel: follows the active dropdown's // geometry with animation (the caelestia-style morph), snaps // instantly when opening from closed. Item { id: chrome property real tX: 0 property real tW: 200 property real tH: 0 property bool flushRight: false property real openH: bar.activeDropdown ? tH : 0 property bool snap: false readonly property real stubW: 32 // Grow-from / shrink-to the widget that owns the dropdown: // the panel opens as a small stub on the button and // expands; closing retargets back to the stub while the // height collapses. function stubX(dd) { return Math.round(Math.min(bar.width - Theme.frameWidth - stubW, Math.max(Theme.frameWidth, dd.dropdownX - stubW / 2))); } // All dropdowns grow from / shrink to their own widget — // flush ones melt onto the frame column as they expand // (the SDF chrome makes that junction liquid, so the old // corner-parked seed workaround is unnecessary). function seedFromButton(dd) { snap = true; tX = stubX(dd); tW = stubW; snap = false; } function shrinkToButton(dd) { tX = stubX(dd); tW = stubW; } x: tX y: Theme.barHeight width: tW height: openH visible: height > 0.5 Binding { target: chrome; property: "tX" value: bar.activeDropdown ? bar.activeDropdown.x : 0 when: bar.activeDropdown !== null restoreMode: Binding.RestoreNone } Binding { target: chrome; property: "tW" value: bar.activeDropdown ? bar.activeDropdown.width : 0 when: bar.activeDropdown !== null restoreMode: Binding.RestoreNone } Binding { target: chrome; property: "tH" value: bar.activeDropdown ? bar.activeDropdown.fullHeight + 4 : 0 when: bar.activeDropdown !== null restoreMode: Binding.RestoreNone } Binding { target: chrome; property: "flushRight" value: bar.activeDropdown ? bar.activeDropdown.alignRight : false when: bar.activeDropdown !== null restoreMode: Binding.RestoreNone } Behavior on tX { enabled: !chrome.snap NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } Behavior on tW { enabled: !chrome.snap NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } Behavior on openH { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } } // 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" Behavior on color { ColorAnimation { duration: Theme.animFade } } radius: modelData.isSeparator ? 0 : 4 Rectangle { visible: modelData.isSeparator anchors.centerIn: parent width: parent.width - 20 height: 1 color: Theme.base03 } Item { visible: !modelData.isSeparator anchors.fill: parent anchors.leftMargin: 10 anchors.rightMargin: 10 SText { id: menuCheck anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: modelData.buttonType !== QsMenuButtonType.None text: modelData.checkState === Qt.Checked ? "\u2713" : "" color: Theme.base0D font.pixelSize: 12 } SText { anchors.left: parent.left anchors.right: menuCheck.visible ? menuCheck.left : parent.right anchors.rightMargin: menuCheck.visible ? 8 : 0 anchors.verticalCenter: parent.verticalCenter text: modelData.text ?? "" color: modelData.enabled ? Theme.base05 : Theme.base03 font.pixelSize: 12 elide: Text.ElideRight } } 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: 268 spacing: 8 // Master volume card Card { width: parent.width Row { spacing: 6 SIcon { anchors.verticalCenter: parent.verticalCenter; text: "volume_up" } SText { anchors.verticalCenter: parent.verticalCenter text: "Master" font.weight: Font.Medium } } Row { width: parent.width spacing: 8 PillSlider { width: parent.width - masterVolLabel.width - 8 anchors.verticalCenter: parent.verticalCenter value: volWidget.sink && volWidget.sink.audio ? Math.min(1, volWidget.sink.audio.volume) : 0 fillColor: volWidget.muted ? Theme.base03 : Theme.base0D onMoved: (v) => { if (volWidget.sink && volWidget.sink.audio) volWidget.sink.audio.volume = v; } } SText { id: masterVolLabel width: 36 text: volWidget.vol + "%" font.pixelSize: 11 horizontalAlignment: Text.AlignRight anchors.verticalCenter: parent.verticalCenter } } // Mute button Rectangle { width: parent.width height: 28 color: masterMuteMa.containsMouse ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: Theme.animFade } } radius: Theme.radiusTiny Row { anchors.centerIn: parent spacing: 6 SIcon { anchors.verticalCenter: parent.verticalCenter text: volWidget.muted ? "volume_off" : "volume_up" font.pixelSize: 15 } SText { anchors.verticalCenter: parent.verticalCenter text: volWidget.muted ? "Unmute" : "Mute" 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; } } } } // Applications card Card { visible: appStreamsCol.childrenRect.height > 0 width: parent.width Row { spacing: 6 SIcon { anchors.verticalCenter: parent.verticalCenter; text: "graphic_eq" } SText { anchors.verticalCenter: parent.verticalCenter text: "Applications" 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] } SText { text: modelData.properties["application.name"] || modelData.name || "Unknown" color: Theme.base04 font.pixelSize: 11 elide: Text.ElideRight width: parent.width } Row { width: parent.width spacing: 8 PillSlider { width: parent.width - appVolLabel.width - 8 anchors.verticalCenter: parent.verticalCenter height: 16 trackH: 4 value: modelData.audio ? Math.min(1, modelData.audio.volume) : 0 fillColor: modelData.audio && modelData.audio.muted ? Theme.base03 : Theme.base0C onMoved: (v) => { if (modelData.audio) modelData.audio.volume = v; } } SText { id: appVolLabel width: 36 text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%" color: Theme.base04 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: 228 spacing: 8 // Connection card Card { width: parent.width cardSpacing: 4 Row { width: parent.width spacing: 6 SIcon { anchors.verticalCenter: parent.verticalCenter text: netWidget.netState === "connected" ? "wifi" : "wifi_off" color: Theme.base05 font.pixelSize: 16 } SText { anchors.verticalCenter: parent.verticalCenter width: parent.width - 22 text: netWidget.netState === "connected" ? netWidget.netConn : "Not connected" color: Theme.base05 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" Behavior on color { ColorAnimation { duration: Theme.animFade } } radius: Theme.radiusTiny SText { anchors.centerIn: parent text: "Disconnect" color: Theme.base08 font.pixelSize: 12 } MouseArea { id: disconnectMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { netDisconnectProc.targetDevice = netWidget.netDevice; netDisconnectProc.running = true; netWidget.netState = "disconnected"; netWidget.netConn = ""; netWidget.netIcon = "wifi_off"; bar.closeAllDropdowns(); netRefreshDelay.start(); } } } } // Available networks card Card { width: parent.width cardSpacing: 4 SText { text: "Available networks" color: Theme.base04 font.pixelSize: 11 } Repeater { model: netWidget.wifiNetworks Rectangle { required property var modelData width: parent.width height: 32 color: netItemMouse.containsMouse ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: Theme.animFade } } radius: Theme.radiusTiny Row { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 8 anchors.right: parent.right anchors.rightMargin: 8 spacing: 8 SIcon { text: { let s = modelData.signal; if (s >= 75) return "signal_wifi_4_bar"; if (s >= 50) return "network_wifi_3_bar"; if (s >= 25) return "network_wifi_2_bar"; return "network_wifi_1_bar"; } color: modelData.active ? Theme.base0B : Theme.base04 font.pixelSize: 16 anchors.verticalCenter: parent.verticalCenter } SText { text: modelData.ssid color: modelData.active ? Theme.base0B : Theme.base05 font.pixelSize: 12 elide: Text.ElideRight width: 140 anchors.verticalCenter: parent.verticalCenter } SIcon { visible: modelData.security !== "" && modelData.security !== "--" text: "lock" color: Theme.base03 font.pixelSize: 13 anchors.verticalCenter: parent.verticalCenter } } MouseArea { id: netItemMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor 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: 208 spacing: 8 // Battery status card Card { width: parent.width Row { width: parent.width spacing: 8 SIcon { text: batteryWidget.batteryIcon color: Theme.base05 font.pixelSize: 22 anchors.verticalCenter: parent.verticalCenter } Column { anchors.verticalCenter: parent.verticalCenter SText { text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "") color: Theme.base05 font.pixelSize: 13 font.weight: Font.Medium } SText { text: batteryWidget.powerDraw.toFixed(1) + " W" + (batteryWidget.timeRemaining !== "" ? " • " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "") color: Theme.base04 font.pixelSize: 11 } } } } // Power profile card Card { width: parent.width cardSpacing: 6 SText { text: "Power Profile" color: Theme.base04 font.pixelSize: 11 } Item { width: parent.width height: 36 // Sliding selection pill — glides between // profiles instead of each button flipping. Rectangle { id: profilePill readonly property int selIdx: batteryWidget.powerProfile === "power-saver" ? 0 : batteryWidget.powerProfile === "performance" ? 2 : 1 width: (parent.width - 8) / 3 height: 36 radius: Theme.radiusSmall color: Theme.base02 border.width: 1 border.color: Theme.base03 x: selIdx * (width + 4) Behavior on x { NumberAnimation { duration: 250; easing.type: Easing.OutExpo } } } Row { anchors.fill: parent spacing: 4 Repeater { model: [ { name: "power-saver", profile: PowerProfile.PowerSaver, label: "energy_savings_leaf", tip: "Saver" }, { name: "balanced", profile: PowerProfile.Balanced, label: "balance", tip: "Balanced" }, { name: "performance", profile: PowerProfile.Performance, label: "speed", tip: "Performance" } ] Rectangle { required property var modelData width: (parent.width - 8) / 3 height: 36 radius: Theme.radiusSmall color: profMouse.containsMouse && batteryWidget.powerProfile !== modelData.name ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: Theme.animFade } } Column { anchors.centerIn: parent spacing: 1 SIcon { anchors.horizontalCenter: parent.horizontalCenter text: modelData.label // Selected = bright, unselected = grey color: batteryWidget.powerProfile === modelData.name ? Theme.base05 : Theme.base04 Behavior on color { ColorAnimation { duration: 200 } } font.pixelSize: 17 } SText { anchors.horizontalCenter: parent.horizontalCenter text: modelData.tip color: batteryWidget.powerProfile === modelData.name ? Theme.base05 : Theme.base04 Behavior on color { ColorAnimation { duration: 200 } } font.pixelSize: 9 } } MouseArea { id: profMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: PowerProfiles.profile = modelData.profile } } } } } } } } ''} // Calendar popup — GNOME-style two-pane panel. // Left: navigable month calendar + 7-day weather strip. // Right: MPRIS media controls + notification list. BarDropdown { id: calPopup dropdownX: bar.width / 2 // ceil: Text metrics give fractional sizes; fractional rect // edges render as soft 2px lines fullWidth: Math.ceil(calRow.width) + 24 fullHeight: Math.ceil(calRow.height) + 24 autoCloseMs: 3000 // Month being viewed; reset to today when the popup opens // (via the setup function passed to bar.toggleDropdown). property int viewYear: clockText.now.getFullYear() property int viewMonth: clockText.now.getMonth() function resetView() { viewYear = clockText.now.getFullYear(); viewMonth = clockText.now.getMonth(); // Runs on every open: an on-screen toast is redundant // once the notification list is visible. if (toastItem.visible) toastItem.hideNow(); } function shiftMonth(d) { let m = viewMonth + d; if (m < 0) { viewMonth = 11; viewYear--; } else if (m > 11) { viewMonth = 0; viewYear++; } else viewMonth = m; } // --- Weather: 7-day forecast, refreshed every 30 min --- property var weatherDays: [] Process { id: weatherProc command: [Commands.weatherFetch] stdout: StdioCollector { onStreamFinished: { try { let j = JSON.parse(text); let out = []; for (let i = 0; i < j.daily.time.length && i < 7; i++) { out.push({ day: new Date(j.daily.time[i] + "T12:00:00").toLocaleDateString(Qt.locale(), "ddd").slice(0, 2), code: j.daily.weather_code[i], max: Math.round(j.daily.temperature_2m_max[i]), min: Math.round(j.daily.temperature_2m_min[i]) }); } if (out.length > 0) calPopup.weatherDays = out; } catch (e) { /* keep previous forecast */ } } } } Timer { interval: 1800000 running: true repeat: true triggeredOnStart: true onTriggered: weatherProc.running = true } // WMO weather codes → Material Symbols. Ranges per // open-meteo: 0 clear, 1-2 partly, 3 overcast, 45-48 fog, // 51-67 drizzle/rain, 71-77 snow, 80-82 rain showers, // 85-86 snow showers, 95+ thunder. function weatherGlyph(code) { if (code === 0) return "clear_day"; if (code <= 2) return "partly_cloudy_day"; if (code === 3) return "cloud"; if (code <= 48) return "foggy"; if (code <= 67) return "rainy"; if (code <= 77) return "cloudy_snowing"; if (code <= 82) return "rainy"; if (code <= 86) return "cloudy_snowing"; return "thunderstorm"; } // --- Media: prefer the actively playing MPRIS player --- property var player: { let ps = Mpris.players.values; for (let i = 0; i < ps.length; i++) { if (ps[i].playbackState === MprisPlaybackState.Playing) return ps[i]; } return ps.length > 0 ? ps[0] : null; } Row { id: calRow anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.topMargin: 12 spacing: 16 opacity: calPopup.open ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } // ── Left pane: calendar card + weather card ── Column { id: calLeftCol width: 7 * 32 + 16 spacing: 8 // Calendar card Card { width: parent.width // Month header: ‹ [Month Year] › — label click jumps to today Item { width: parent.width height: 28 Rectangle { width: 28; height: 28; radius: Theme.radiusSmall anchors.left: parent.left color: calPrevMa.containsMouse ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: Theme.animFade } } SIcon { anchors.centerIn: parent text: "chevron_left" color: Theme.base05 font.pixelSize: 18 } MouseArea { id: calPrevMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: calPopup.shiftMonth(-1) } } SText { anchors.centerIn: parent text: new Date(calPopup.viewYear, calPopup.viewMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy") color: Theme.base05 font.pixelSize: 14 font.weight: Font.Medium MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: calPopup.resetView() } } Rectangle { width: 28; height: 28; radius: Theme.radiusSmall anchors.right: parent.right color: calNextMa.containsMouse ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: Theme.animFade } } SIcon { anchors.centerIn: parent text: "chevron_right" color: Theme.base05 font.pixelSize: 18 } MouseArea { id: calNextMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: calPopup.shiftMonth(1) } } } Row { spacing: 0 Repeater { model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] SText { required property var modelData width: 32 horizontalAlignment: Text.AlignHCenter text: modelData color: Theme.base04 font.pixelSize: 13 } } } Grid { columns: 7 spacing: 0 Repeater { model: 42 Rectangle { required property int index property int dayNum: { let first = new Date(calPopup.viewYear, calPopup.viewMonth, 1); let startDay = (first.getDay() + 6) % 7; return index - startDay + 1; } property int daysInMonth: new Date(calPopup.viewYear, calPopup.viewMonth + 1, 0).getDate() property bool isToday: dayNum === clockText.now.getDate() && calPopup.viewMonth === clockText.now.getMonth() && calPopup.viewYear === clockText.now.getFullYear() width: 32 height: 26 radius: Theme.radiusTiny color: isToday ? Theme.base03 : "transparent" SText { anchors.centerIn: parent text: parent.dayNum >= 1 && parent.dayNum <= parent.daysInMonth ? parent.dayNum.toString() : "" color: parent.isToday ? Theme.base05 : Theme.base04 font.pixelSize: 13 } } } } } // Weather card Rectangle { width: parent.width height: weatherRow.height + 16 radius: Theme.radius color: Theme.base01 visible: calPopup.weatherDays.length > 0 Row { id: weatherRow anchors.top: parent.top anchors.topMargin: 8 anchors.horizontalCenter: parent.horizontalCenter Repeater { model: calPopup.weatherDays Column { required property var modelData width: 32 spacing: 2 SText { anchors.horizontalCenter: parent.horizontalCenter text: modelData.day color: Theme.base04 font.pixelSize: 10 } SIcon { anchors.horizontalCenter: parent.horizontalCenter text: calPopup.weatherGlyph(modelData.code) color: Theme.base0C font.pixelSize: 16 } SText { anchors.horizontalCenter: parent.horizontalCenter text: modelData.max + "°" color: Theme.base05 font.pixelSize: 10 } SText { anchors.horizontalCenter: parent.horizontalCenter text: modelData.min + "°" color: Theme.base03 font.pixelSize: 10 } } } } } } // ── Right pane: media + notifications ── Column { id: calRightCol width: 300 spacing: 8 // Media player card Rectangle { width: parent.width height: 64 radius: Theme.radius color: Theme.base01 visible: calPopup.player !== null Row { anchors.fill: parent anchors.margins: 8 spacing: 10 Rectangle { width: 48; height: 48 radius: Theme.radiusSmall anchors.verticalCenter: parent.verticalCenter color: Theme.base02 clip: true SIcon { anchors.centerIn: parent visible: albumArt.status !== Image.Ready text: "music_note" color: Theme.base04 font.pixelSize: 22 } Image { id: albumArt anchors.fill: parent fillMode: Image.PreserveAspectCrop source: calPopup.player ? calPopup.player.trackArtUrl : "" } } Column { width: parent.width - 48 - 10 - 88 - 10 anchors.verticalCenter: parent.verticalCenter spacing: 2 SText { width: parent.width text: calPopup.player ? calPopup.player.trackTitle : "" color: Theme.base05 font.pixelSize: 12 font.weight: Font.Medium elide: Text.ElideRight } SText { width: parent.width text: calPopup.player ? calPopup.player.trackArtist : "" color: Theme.base04 font.pixelSize: 11 elide: Text.ElideRight } } Row { anchors.verticalCenter: parent.verticalCenter spacing: 2 Repeater { model: [ { glyph: "skip_previous", act: "prev" }, { glyph: calPopup.player && calPopup.player.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" }, { glyph: "skip_next", act: "next" } ] Rectangle { id: mediaBtn required property var modelData width: 28; height: 28; radius: 14 color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: Theme.animFade } } SIcon { anchors.centerIn: parent text: mediaBtn.modelData.glyph color: Theme.base05 font.pixelSize: 18 } MouseArea { id: mediaBtnMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { let p = calPopup.player; if (!p) return; if (mediaBtn.modelData.act === "prev") p.previous(); else if (mediaBtn.modelData.act === "next") p.next(); else p.togglePlaying(); } } } } } } } // Notifications card Card { width: parent.width cardSpacing: 6 Item { width: parent.width height: 20 SText { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: "Notifications" font.weight: Font.Medium } SText { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" color: Theme.base04 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(); } } } } } SText { visible: bar.notifServer.trackedNotifications.values.length === 0 text: "No notifications" color: Theme.base03 font.pixelSize: 11 anchors.horizontalCenter: parent.horizontalCenter } Repeater { model: bar.notifServer.trackedNotifications Rectangle { id: notifItem required property var modelData width: parent.width height: ncBody.height + 16 radius: Theme.radiusSmall color: Theme.base02 NotifContent { id: ncBody notif: notifItem.modelData anchors.left: parent.left anchors.right: dismissBtn.left anchors.top: parent.top anchors.margins: 8 } SIcon { id: dismissBtn anchors.right: parent.right anchors.top: parent.top anchors.margins: 8 text: "close" color: dismissMa.containsMouse ? Theme.base05 : Theme.base03 font.pixelSize: 15 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: Theme.barHeight width: _toastRect.width + 16 height: _toastRect.height + 4 Process { id: notifSoundProc command: [Commands.notifSound, "-i", "message"] } Connections { target: bar.shellRoot function onNotificationReceived() { if (!toastItem.isPrimary) return; let n = bar.shellRoot.latestNotification; // Popup open: the notification list is already on // screen — play the sound but skip the toast. if (calPopup.visible) { if (!toastItem.mutedApps.includes(n.appName)) { notifSoundProc.running = true; } return; } toastItem.showToast(n); } } 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(); } // Instant hide, no close animation function hideNow() { _toastTimer.stop(); _toastCloseDelay.stop(); toastOpen = false; visible = false; } 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(); } } Item { id: _toastRect anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top width: 320 height: toastItem.toastOpen ? toastCard.height + 12 : 0 clip: true Behavior on height { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } // Notification sits in a base02 rounded card, matching // the calendar list. Inset 6px so the melt panel frames it. Rectangle { id: toastCard anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.margins: 6 height: toastCol.height + 16 radius: Theme.radiusSmall color: Theme.base02 NotifContent { id: toastCol notif: toastItem.currentNotif anchors.left: parent.left anchors.right: toastDismiss.left anchors.top: parent.top anchors.margins: 8 onActionInvoked: toastItem.dismiss() } SIcon { id: toastDismiss anchors.right: parent.right anchors.top: parent.top anchors.margins: 8 text: "close" color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03 font.pixelSize: 15 MouseArea { id: toastDismissMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); } } } } } } } ''; }; }; }; }; }