# 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, 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; # 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 Launcher 1.0 Launcher.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}" readonly property string fontFamily: "${monoFont}" // 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 } ''; }; "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 signal notificationReceived() Launcher { id: launcher } // Bound in hyprland.nix: Super+R → toggle, Super+L → powermenu IpcHandler { target: "launcher" function toggle(): void { launcher.toggleMode("apps"); } function powermenu(): void { launcher.toggleMode("power"); } } // 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 } } } ''; }; # App launcher + power menu (replaces anyrun). Full-screen transparent # overlay with exclusive keyboard focus while open; Esc / click-outside # closes. Apps come from Quickshell's DesktopEntries service. "quickshell/Launcher.qml" = { onChange = qsRestart; text = '' import Quickshell import Quickshell.Wayland import Quickshell.Hyprland import QtQuick PanelWindow { id: root // "apps" (Super+R) or "power" (Super+L) property string mode: "apps" visible: false screen: Quickshell.screens[0] WlrLayershell.namespace: "quickshell-launcher" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore color: "transparent" anchors { top: true bottom: true left: true right: true } function toggleMode(m) { if (visible && mode === m) { close(); return; } mode = m; search.text = ""; list.currentIndex = 0; visible = true; search.forceActiveFocus(); } function close() { visible = false; } // Lock/reboot/shutdown spawn via Quickshell.execDetached — fully // detached, so a quickshell restart can never kill a running // hyprlock. Logout (empty cmd) goes through Hyprland IPC; with a // Lua config the dispatch body is evaluated as a Lua dispatcher // expression, so it must use hl.dsp.* syntax, not hyprlang's. readonly property var powerActions: [ { name: "Lock", glyph: "", cmd: [Commands.hyprlock] }, { name: "Logout", glyph: "", cmd: [] }, { name: "Reboot", glyph: "", cmd: [Commands.systemctl, "reboot"] }, { name: "Shutdown", glyph: "", cmd: [Commands.systemctl, "poweroff"] } ] 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 = search.text.toLowerCase().trim(); if (mode === "power") { return powerActions.filter(a => q === "" || score(a.name, "", q) > 0); } 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); } function activate(item) { if (!item) return; if (mode === "power") { if (item.cmd.length === 0) Hyprland.dispatch("hl.dsp.exit()"); else Quickshell.execDetached(item.cmd); } else { item.execute(); } close(); } // Click outside the box closes MouseArea { anchors.fill: parent onClicked: root.close() } Rectangle { id: box x: Math.round((parent.width - width) / 2) y: Math.round(parent.height * 0.25) width: 350 height: col.height + 16 radius: 8 // matches hyprland decoration.rounding color: Theme.base00 border.width: Theme.borderWidth border.color: Theme.base03 // Swallow clicks inside the box MouseArea { anchors.fill: parent } Column { id: col anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.margins: 8 spacing: 6 Rectangle { width: parent.width height: 36 radius: 6 color: Theme.base01 TextInput { id: search anchors.fill: parent anchors.leftMargin: 12 anchors.rightMargin: 12 verticalAlignment: TextInput.AlignVCenter color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 13 clip: true onTextChanged: list.currentIndex = 0 Keys.onEscapePressed: root.close() Keys.onUpPressed: list.currentIndex = Math.max(0, list.currentIndex - 1) Keys.onDownPressed: list.currentIndex = Math.min(root.entries.length - 1, list.currentIndex + 1) Keys.onReturnPressed: root.activate(root.entries[list.currentIndex]) Keys.onEnterPressed: root.activate(root.entries[list.currentIndex]) Keys.onTabPressed: list.currentIndex = (list.currentIndex + 1) % Math.max(1, root.entries.length) } Text { anchors.fill: search verticalAlignment: Text.AlignVCenter visible: search.text === "" text: root.mode === "power" ? "Power" : "Search" color: Theme.base03 font.family: Theme.fontFamily font.pixelSize: 13 } } ListView { id: list width: parent.width height: contentHeight interactive: false model: root.entries delegate: Rectangle { required property var modelData required property int index width: list.width height: 32 radius: 6 color: list.currentIndex === index ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: 100 } } Row { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 10 spacing: 10 Image { visible: root.mode === "apps" && source != "" anchors.verticalCenter: parent.verticalCenter width: 18 height: 18 sourceSize.width: 18 sourceSize.height: 18 source: root.mode === "apps" ? Quickshell.iconPath(modelData.icon, true) : "" } Text { visible: root.mode === "power" anchors.verticalCenter: parent.verticalCenter text: root.mode === "power" ? modelData.glyph : "" color: Theme.base0D font.family: Theme.fontFamily font.pixelSize: 14 } Text { anchors.verticalCenter: parent.verticalCenter text: modelData.name color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 13 elide: Text.ElideRight width: 270 } } MouseArea { anchors.fill: parent hoverEnabled: true onEntered: list.currentIndex = index onClicked: root.activate(modelData) } } } } } } ''; }; "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 QtQuick.Layouts import QtQuick.Shapes 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 } // ── Screen frame: a bar-coloured band around the monitor. // Fill is one donut path (screen rect minus rounded inner // cutout); the inner border is one continuous open path from // the top-left corner the long way around to the bottom-right // corner. The right column's border is separate so it can // open up where a flush-right dropdown merges into it. Shape { anchors.fill: parent preferredRendererType: Shape.CurveRenderer ShapePath { fillColor: Theme.barBg strokeWidth: -1 fillRule: ShapePath.OddEvenFill startX: 0; startY: 30 PathLine { x: bar.width; y: 30 } PathLine { x: bar.width; y: bar.height } PathLine { x: 0; y: bar.height } PathLine { x: 0; y: 30 } PathMove { x: Theme.frameWidth + 8; y: 30 } PathLine { x: bar.width - Theme.frameWidth - 8; y: 30 } PathArc { x: bar.width - Theme.frameWidth; y: 38; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise } PathLine { x: bar.width - Theme.frameWidth; y: bar.height - Theme.frameWidth - 8 } PathArc { x: bar.width - Theme.frameWidth - 8; y: bar.height - Theme.frameWidth; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise } PathLine { x: Theme.frameWidth + 8; y: bar.height - Theme.frameWidth } PathArc { x: Theme.frameWidth; y: bar.height - Theme.frameWidth - 8; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise } PathLine { x: Theme.frameWidth; y: 38 } PathArc { x: Theme.frameWidth + 8; y: 30; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise } } ShapePath { fillColor: "transparent" strokeColor: Theme.base03 strokeWidth: Theme.borderWidth capStyle: ShapePath.FlatCap startX: Theme.frameWidth + 8; startY: 30 PathArc { x: Theme.frameWidth; y: 38; radiusX: 8; radiusY: 8; direction: PathArc.Counterclockwise } PathLine { x: Theme.frameWidth; y: bar.height - Theme.frameWidth - 8 } PathArc { x: Theme.frameWidth + 8; y: bar.height - Theme.frameWidth; radiusX: 8; radiusY: 8; direction: PathArc.Counterclockwise } PathLine { x: bar.width - Theme.frameWidth - 8; y: bar.height - Theme.frameWidth } PathArc { x: bar.width - Theme.frameWidth; y: bar.height - Theme.frameWidth - 8; radiusX: 8; radiusY: 8; direction: PathArc.Counterclockwise } } } // Frame right-column inner border — starts below a flush-right // dropdown's bottom curve and follows the morph. The short y // animation softens the dock/undock jump. Rectangle { x: bar.width - Theme.frameWidth - Theme.borderWidth / 2 y: chrome.mergedRight ? 30 + chrome.height + 8 : 38 width: Theme.borderWidth height: Math.max(0, bar.height - Theme.frameWidth - 8 - y) color: Theme.base03 Behavior on y { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } } // Frame top-right inner corner — fades out while a flush-right // dropdown is merged into the column there. Shape { opacity: chrome.mergedRight ? 0 : 1 visible: opacity > 0.01 Behavior on opacity { NumberAnimation { duration: 90 } } preferredRendererType: Shape.CurveRenderer ShapePath { fillColor: "transparent" strokeColor: Theme.base03 strokeWidth: Theme.borderWidth capStyle: ShapePath.FlatCap startX: bar.width - Theme.frameWidth - 8; startY: 30 PathArc { x: bar.width - Theme.frameWidth; y: 38; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise } } } // The "gap source" for the bar border — the morphing chrome // panel takes priority, then the toast. Tracking the animated // chrome means the border gap follows the morph. property bool hasGap: chrome.visible || (toastItem.visible && _toastRect.height > 0) property real gapLeft: chrome.visible ? chrome.x : toastItem.visible && _toastRect.height > 0 ? toastItem.x : 0 property real gapRight: chrome.visible ? chrome.x + chrome.width : toastItem.visible && _toastRect.height > 0 ? toastItem.x + toastItem.width : 0 // Bar bottom border — left segment (up to gap). Centered on // y=30 so it runs into the panel's edge-centered border stroke. Rectangle { id: barBorderLeft x: Theme.frameWidth + 8 y: 30 - Theme.borderWidth / 2 width: Math.max(0, (bar.hasGap ? bar.gapLeft : bar.width - Theme.frameWidth - 8) - x) height: Theme.borderWidth color: Theme.base03 } // Bar bottom border — right segment (after gap) Rectangle { id: barBorderRight visible: bar.hasGap x: bar.gapRight y: 30 - Theme.borderWidth / 2 width: Math.max(0, bar.width - Theme.frameWidth - 8 - x) height: Theme.borderWidth 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 (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: 30 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: 120 } } } 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 } Text { 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.family: Theme.fontFamily 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: 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: Theme.fontFamily 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: [] // 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" ? "\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: Theme.fontFamily 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 // 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 ? "\u{f0084}" : batteryLevel >= 90 ? "\u{f0079}" : batteryLevel >= 70 ? "\u{f0082}" : batteryLevel >= 50 ? "\u{f007f}" : batteryLevel >= 30 ? "\u{f007c}" : batteryLevel >= 15 ? "\u{f007a}" : "\u{f008e}" 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: Theme.fontFamily font.pixelSize: 13 } Text { id: batteryIconText text: batteryWidget.batteryIcon color: batteryWidget.batteryLevel <= 15 ? Theme.base08 : batteryWidget.batteryLevel <= 30 ? Theme.base0A : Theme.base05 font.family: Theme.fontFamily font.pixelSize: 14 } } 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: 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(); } } } } } } } // 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: 30 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: 200; easing.type: Easing.OutCubic } } Item { id: dropdownContent x: 8 - _dropdownRect.x width: dropdown.fullWidth height: dropdown.fullHeight } } } // Panel silhouette (caelestia-inspired): fill and border are // each ONE continuous path — concave ear, side, rounded bottom // corners, side, ear — so there are no seams or junctions at // all. The border path is open at the top where the panel // joins the bar; the bar's own border strips meet it there. component PanelShape: Shape { id: pshape readonly property real ear: 8 readonly property real rr: Math.min(8, Math.max(1, height / 2)) preferredRendererType: Shape.CurveRenderer ShapePath { fillColor: Theme.barBg strokeWidth: -1 startX: 0; startY: 0 PathArc { x: pshape.ear; y: Math.min(pshape.ear, pshape.height); radiusX: pshape.ear; radiusY: pshape.ear; direction: PathArc.Clockwise } PathLine { x: pshape.ear; y: pshape.height - pshape.rr } PathArc { x: pshape.ear + pshape.rr; y: pshape.height; radiusX: pshape.rr; radiusY: pshape.rr; direction: PathArc.Counterclockwise } PathLine { x: pshape.width - pshape.ear - pshape.rr; y: pshape.height } PathArc { x: pshape.width - pshape.ear; y: pshape.height - pshape.rr; radiusX: pshape.rr; radiusY: pshape.rr; direction: PathArc.Counterclockwise } PathLine { x: pshape.width - pshape.ear; y: Math.min(pshape.ear, pshape.height) } PathArc { x: pshape.width; y: 0; radiusX: pshape.ear; radiusY: pshape.ear; direction: PathArc.Clockwise } PathLine { x: 0; y: 0 } } ShapePath { fillColor: "transparent" strokeColor: Theme.base03 strokeWidth: Theme.borderWidth capStyle: ShapePath.FlatCap startX: 0; startY: 0 PathArc { x: pshape.ear; y: Math.min(pshape.ear, pshape.height); radiusX: pshape.ear; radiusY: pshape.ear; direction: PathArc.Clockwise } PathLine { x: pshape.ear; y: pshape.height - pshape.rr } PathArc { x: pshape.ear + pshape.rr; y: pshape.height; radiusX: pshape.rr; radiusY: pshape.rr; direction: PathArc.Counterclockwise } PathLine { x: pshape.width - pshape.ear - pshape.rr; y: pshape.height } PathArc { x: pshape.width - pshape.ear; y: pshape.height - pshape.rr; radiusX: pshape.rr; radiusY: pshape.rr; direction: PathArc.Counterclockwise } PathLine { x: pshape.width - pshape.ear; y: Math.min(pshape.ear, pshape.height) } PathArc { x: pshape.width; y: 0; radiusX: pshape.ear; radiusY: pshape.ear; direction: PathArc.Clockwise } } } // Flush-right variant: no right ear; the bottom-right curve // merges the panel into the screen frame's right column. component PanelShapeFlush: Shape { id: pshapeF readonly property real ear: 8 readonly property real rr: Math.min(8, Math.max(1, height / 2)) preferredRendererType: Shape.CurveRenderer ShapePath { fillColor: Theme.barBg strokeWidth: -1 startX: 0; startY: 0 PathArc { x: pshapeF.ear; y: Math.min(pshapeF.ear, pshapeF.height); radiusX: pshapeF.ear; radiusY: pshapeF.ear; direction: PathArc.Clockwise } PathLine { x: pshapeF.ear; y: pshapeF.height - pshapeF.rr } PathArc { x: pshapeF.ear + pshapeF.rr; y: pshapeF.height; radiusX: pshapeF.rr; radiusY: pshapeF.rr; direction: PathArc.Counterclockwise } PathLine { x: pshapeF.width - pshapeF.ear; y: pshapeF.height } PathArc { x: pshapeF.width; y: pshapeF.height + pshapeF.ear; radiusX: pshapeF.ear; radiusY: pshapeF.ear; direction: PathArc.Clockwise } PathLine { x: pshapeF.width; y: 0 } PathLine { x: 0; y: 0 } } ShapePath { fillColor: "transparent" strokeColor: Theme.base03 strokeWidth: Theme.borderWidth capStyle: ShapePath.FlatCap startX: 0; startY: 0 PathArc { x: pshapeF.ear; y: Math.min(pshapeF.ear, pshapeF.height); radiusX: pshapeF.ear; radiusY: pshapeF.ear; direction: PathArc.Clockwise } PathLine { x: pshapeF.ear; y: pshapeF.height - pshapeF.rr } PathArc { x: pshapeF.ear + pshapeF.rr; y: pshapeF.height; radiusX: pshapeF.rr; radiusY: pshapeF.rr; direction: PathArc.Counterclockwise } PathLine { x: pshapeF.width - pshapeF.ear; y: pshapeF.height } PathArc { x: pshapeF.width; y: pshapeF.height + pshapeF.ear; radiusX: pshapeF.ear; radiusY: pshapeF.ear; direction: PathArc.Clockwise } } } // 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 // True only while the panel's right edge actually touches // the frame column — the flush shape and the column-border // gap key off this, so a detaching (shrinking) panel // releases the column instead of dragging its border. readonly property bool mergedRight: visible && flushRight && x + width >= bar.width - Theme.frameWidth - 0.5 // 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))); } function seedFromButton(dd) { snap = true; tX = stubX(dd); tW = stubW; snap = false; } function shrinkToButton(dd) { tX = stubX(dd); tW = stubW; } x: tX y: 30 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: 280; easing.type: Easing.OutExpo } } Behavior on tW { enabled: !chrome.snap NumberAnimation { duration: 280; easing.type: Easing.OutExpo } } Behavior on openH { NumberAnimation { duration: 280; easing.type: Easing.OutExpo } } // Crossfade between the floating and column-merged // silhouettes at dock/undock; the brief double-draw of // the (near-identical) bodies is imperceptible over blur. PanelShape { opacity: chrome.mergedRight ? 0 : 1 visible: opacity > 0.01 Behavior on opacity { NumberAnimation { duration: 90 } } width: chrome.width height: chrome.height } PanelShapeFlush { opacity: chrome.mergedRight ? 1 : 0 visible: opacity > 0.01 Behavior on opacity { NumberAnimation { duration: 90 } } width: chrome.width height: chrome.height } } // 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: 120 } } 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: Theme.fontFamily font.pixelSize: 12 elide: Text.ElideRight } Text { visible: modelData.buttonType !== QsMenuButtonType.None text: modelData.checkState === Qt.Checked ? "\u2713" : "" color: Theme.base0D font.family: Theme.fontFamily 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: Theme.fontFamily 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: Theme.fontFamily 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: 120 } } radius: 4 Text { anchors.centerIn: parent text: volWidget.muted ? "\u{f0581} Unmute" : "\u{f057e} Mute" color: Theme.base05 font.family: Theme.fontFamily 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: Theme.fontFamily 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: Theme.fontFamily 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: Theme.fontFamily 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: Theme.fontFamily 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: 120 } } radius: 4 Text { anchors.centerIn: parent text: "Disconnect" color: Theme.base08 font.family: Theme.fontFamily 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: Theme.fontFamily font.pixelSize: 11 topPadding: 2 } Repeater { model: netWidget.wifiNetworks Rectangle { required property var modelData width: 220 height: 32 color: netItemMouse.containsMouse ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: 120 } } 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{f0928}"; // strength 4 if (s >= 50) return "\u{f0925}"; // strength 3 if (s >= 25) return "\u{f0922}"; // strength 2 return "\u{f091f}"; // strength 1 } color: modelData.active ? Theme.base0B : Theme.base04 font.family: Theme.fontFamily font.pixelSize: 13 anchors.verticalCenter: parent.verticalCenter } Text { text: modelData.ssid color: modelData.active ? Theme.base0B : Theme.base05 font.family: Theme.fontFamily 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: Theme.fontFamily 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: Theme.fontFamily font.pixelSize: 18 anchors.verticalCenter: parent.verticalCenter } Column { anchors.verticalCenter: parent.verticalCenter Text { text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "") color: Theme.base05 font.family: Theme.fontFamily 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: Theme.fontFamily 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: Theme.fontFamily font.pixelSize: 11 } Row { width: parent.width spacing: 4 Repeater { model: [ { name: "power-saver", profile: PowerProfile.PowerSaver, label: "\u{f0425}", tip: "Saver" }, { name: "balanced", profile: PowerProfile.Balanced, label: "\u{f0376}", tip: "Balanced" }, { name: "performance", profile: PowerProfile.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" Behavior on color { ColorAnimation { duration: 120 } } 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: Theme.fontFamily font.pixelSize: 14 } Text { anchors.horizontalCenter: parent.horizontalCenter text: modelData.tip color: Theme.base04 font.family: Theme.fontFamily font.pixelSize: 9 } } MouseArea { id: profMouse anchors.fill: parent hoverEnabled: true 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 } function weatherGlyph(code) { if (code === 0) return "\u{f0599}"; // sunny if (code <= 2) return "\u{f0595}"; // partly cloudy if (code === 3) return "\u{f0590}"; // overcast if (code <= 48) return "\u{f0591}"; // fog if (code <= 57) return "\u{f0597}"; // drizzle if (code <= 67) return "\u{f0596}"; // rain if (code <= 77) return "\u{f0598}"; // snow if (code <= 82) return "\u{f0597}"; // showers if (code <= 86) return "\u{f0598}"; // snow showers return "\u{f0593}"; // thunder } // --- 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 Rectangle { width: parent.width height: calCardCol.height + 16 radius: 8 color: Theme.base01 Column { id: calCardCol anchors.top: parent.top anchors.topMargin: 8 anchors.horizontalCenter: parent.horizontalCenter width: 7 * 32 spacing: 8 // Month header: ‹ [Month Year] › — label click jumps to today Item { width: parent.width height: 28 Rectangle { width: 28; height: 28; radius: 6 anchors.left: parent.left color: calPrevMa.containsMouse ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: 120 } } Text { anchors.centerIn: parent text: "\u{f0141}" color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 16 } MouseArea { id: calPrevMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: calPopup.shiftMonth(-1) } } Text { anchors.centerIn: parent text: new Date(calPopup.viewYear, calPopup.viewMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy") color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 14 font.weight: Font.Medium MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: calPopup.resetView() } } Rectangle { width: 28; height: 28; radius: 6 anchors.right: parent.right color: calNextMa.containsMouse ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: 120 } } Text { anchors.centerIn: parent text: "\u{f0142}" color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 16 } 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"] Text { required property var modelData width: 32 horizontalAlignment: Text.AlignHCenter text: modelData color: Theme.base04 font.family: Theme.fontFamily 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: 4 color: isToday ? Theme.base03 : "transparent" Text { anchors.centerIn: parent text: parent.dayNum >= 1 && parent.dayNum <= parent.daysInMonth ? parent.dayNum.toString() : "" color: parent.isToday ? Theme.base05 : Theme.base04 font.family: Theme.fontFamily font.pixelSize: 13 } } } } } } // Weather card Rectangle { width: parent.width height: weatherRow.height + 16 radius: 8 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 Text { anchors.horizontalCenter: parent.horizontalCenter text: modelData.day color: Theme.base04 font.family: Theme.fontFamily font.pixelSize: 10 } Text { anchors.horizontalCenter: parent.horizontalCenter text: calPopup.weatherGlyph(modelData.code) color: Theme.base0C font.family: Theme.fontFamily font.pixelSize: 14 } Text { anchors.horizontalCenter: parent.horizontalCenter text: modelData.max + "°" color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 10 } Text { anchors.horizontalCenter: parent.horizontalCenter text: modelData.min + "°" color: Theme.base03 font.family: Theme.fontFamily font.pixelSize: 10 } } } } } } // ── Right pane: media + notifications ── Column { id: calRightCol width: 300 spacing: 8 // Media player card Rectangle { width: parent.width height: 64 radius: 8 color: Theme.base01 visible: calPopup.player !== null Row { anchors.fill: parent anchors.margins: 8 spacing: 10 Rectangle { width: 48; height: 48 radius: 6 anchors.verticalCenter: parent.verticalCenter color: Theme.base02 clip: true Text { anchors.centerIn: parent visible: albumArt.status !== Image.Ready text: "\u{f0387}" color: Theme.base04 font.family: Theme.fontFamily font.pixelSize: 20 } 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 Text { width: parent.width text: calPopup.player ? calPopup.player.trackTitle : "" color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 12 font.weight: Font.Medium elide: Text.ElideRight } Text { width: parent.width text: calPopup.player ? calPopup.player.trackArtist : "" color: Theme.base04 font.family: Theme.fontFamily font.pixelSize: 11 elide: Text.ElideRight } } Row { anchors.verticalCenter: parent.verticalCenter spacing: 2 Repeater { model: [ { glyph: "\u{f04ae}", act: "prev" }, { glyph: calPopup.player && calPopup.player.playbackState === MprisPlaybackState.Playing ? "\u{f03e4}" : "\u{f040a}", act: "toggle" }, { glyph: "\u{f04ad}", 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: 120 } } Text { anchors.centerIn: parent text: mediaBtn.modelData.glyph color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 16 } 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 Rectangle { width: parent.width height: notifCardCol.height + 16 radius: 8 color: Theme.base01 Column { id: notifCardCol anchors.top: parent.top anchors.topMargin: 8 anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 16 spacing: 6 Item { width: parent.width height: 20 Text { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: "Notifications" color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 13 font.weight: Font.Medium } Text { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" color: Theme.base04 font.family: Theme.fontFamily 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(); } } } } } Text { visible: bar.notifServer.trackedNotifications.values.length === 0 text: "No notifications" color: Theme.base03 font.family: Theme.fontFamily font.pixelSize: 11 anchors.horizontalCenter: parent.horizontalCenter } Repeater { model: bar.notifServer.trackedNotifications Rectangle { id: notifItem required property var modelData width: notifCardCol.width height: notifCol.height + 12 radius: 6 color: Theme.base02 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: Theme.fontFamily font.pixelSize: 11 font.weight: Font.Medium elide: Text.ElideRight } Text { width: parent.width text: notifItem.modelData.body || "" color: Theme.base04 font.family: Theme.fontFamily 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.base03 : Theme.base02 Behavior on color { ColorAnimation { duration: 120 } } border.width: 1 border.color: Theme.base03 Text { id: actionText anchors.centerIn: parent text: modelData.text color: Theme.base05 font.family: Theme.fontFamily 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: Theme.fontFamily 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: _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(); } } // Same single-path silhouette as the dropdown chrome PanelShape { width: toastItem.width height: _toastRect.height } Item { id: _toastRect anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top width: 320 height: toastItem.toastOpen ? toastCol.height + 16 : 0 clip: true Behavior on height { NumberAnimation { duration: 280; easing.type: Easing.OutExpo } } 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: Theme.fontFamily 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: Theme.fontFamily 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 Behavior on color { ColorAnimation { duration: 120 } } border.width: 1 border.color: Theme.base02 Text { id: toastActionText anchors.centerIn: parent text: modelData.text color: Theme.base05 font.family: Theme.fontFamily 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: Theme.fontFamily font.pixelSize: 13 MouseArea { id: toastDismissMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); } } } } } } ''; }; }; }; }; }