# 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"; ui = import ./ui.nix; # shared corner-rounding knob 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 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 (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. # Nova Pro Wireless ANC, no daemon: talk straight to the base station's # vendor HID interface (iface 4). Report id 0x06 leads every 64-byte # packet; opcode 0xbd sets ANC (0 off / 1 transparent / 2 on), 0xb0 # requests full status (anc @ 0x0a, power @ 0x0f, 0x08 = on-head). # Protocol lifted from Arctis-Sound-Manager's nova_pro_wireless.yaml. novaAncScript = pkgs.writeScript "nova-anc" '' #!${pkgs.python3}/bin/python3 """nova-anc [off|transparent|on] — no arg prints current mode.""" import glob, os, select, sys, time MODES = {"off": 0, "transparent": 1, "on": 2} NAMES = {0: "off", 1: "transparent", 2: "on"} PIDS = {0x12e0, 0x12e5, 0x225d} def dongle(): for h in glob.glob("/sys/class/hidraw/hidraw*"): try: uevent = open(h + "/device/uevent").read() iface = open(h + "/device/../bInterfaceNumber").read().strip() except OSError: continue for line in uevent.splitlines(): if line.startswith("HID_ID="): _, vid, pid = line.split("=")[1].split(":") if int(vid, 16) == 0x1038 and int(pid, 16) in PIDS and iface == "04": return "/dev/" + os.path.basename(h) return None dev = dongle() if not dev: sys.exit(1) fd = os.open(dev, os.O_RDWR) if len(sys.argv) > 1: os.write(fd, bytes([0x06, 0xbd, MODES[sys.argv[1]]]).ljust(64, b"\0")) print(sys.argv[1]) sys.exit(0) os.write(fd, bytes([0x06, 0xb0]).ljust(64, b"\0")) deadline = time.time() + 1.0 while (left := deadline - time.time()) > 0: if not select.select([fd], [], [], left)[0]: break pkt = os.read(fd, 64) # Skip unsolicited 0x07xx event packets until the status reply if len(pkt) > 0x0f and pkt[0] == 0x06 and pkt[1] == 0xb0: if pkt[0x0f] != 0x08: break # headset off / in cradle -> report nothing print(NAMES.get(pkt[0x0a], "off")) sys.exit(0) sys.exit(1) ''; 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,sunrise,sunset&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 Lock 1.0 Lock.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 base09: "#${c.base09}" 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}" // Card surfaces are translucent so the compositor's bar-layer // blur shows through — a lessened blur over the panel tint. readonly property color cardBg: "#CC${c.base01}" // Transparent base02: hover highlights fade to/from this so the // colour animation stays in-hue instead of dipping through // transparent *black* (which reads as an extra flash colour). readonly property color base02t: "#00${c.base02}" 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 thickness. The bar is a VERTICAL column on the left // edge, so this is its width: it drives widget cell widths, the // dropdown x-origin and the shader cutout geometry — change // here, not in ~15 places. Wide enough for the stacked HH/mm // clock, which is what sets the floor. readonly property int barWidth: 46 // ── Rounding. ONE knob: settings/ui.nix `radius`. Everything // below derives from it, and hyprland's window rounding reads // the same value, so changing that one number rescales the // roundness of the entire desktop. readonly property int radius: ${toString ui.radius} // panels, dropdowns, screen cutout // Concentric, NOT a factor: a card inside a panel needs the // panel's radius MINUS the gap between them. Give it the same // radius as its container and the inner corner is over-round // for its size — the card reads as inflated/balloon-like, // invisible at r=8 and obvious by r=32. readonly property int radiusCard: radius - panelGap readonly property int radiusSmall: Math.round(radius * 0.75) // pill buttons, workspace dots readonly property int radiusTiny: Math.round(radius * 0.5) // hover rows, action chips readonly property int cardPad: 8 // card inner padding (inset = 2×) readonly property int panelGap: 8 // gap between panel border and cards (all dropdowns/menus/toast) // ── 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/Lock.qml" = { onChange = qsRestart; text = '' import Quickshell import Quickshell.Io import Quickshell.Wayland import Quickshell.Services.Pam import QtQuick import Qt5Compat.GraphicalEffects // ext-session-lock screen, replacing hyprlock. Set `locked: true` // and the compositor hands every screen to a LockSurface below // until PAM says otherwise. // // PAM and state live in a Scope OUTSIDE WlSessionLock: its default // property is the surface *component*, so a PamContext declared // inside it is never instantiated — `pam` didn't resolve and // submit() threw, which is why enter/the arrow did nothing. Scope { id: root property alias locked: lock.locked property string password: "" property bool failed: false function submit(pw) { if (pam.active || pw === "") return; failed = false; password = pw; pam.start(); } // greetd autologs us in, so lock as soon as the shell comes up: // the lockscreen is the login prompt. mkdir is the atomic // once-per-session test — XDG_RUNTIME_DIR dies with the // session, so quickshell restarts (rebuilds) don't re-lock. Process { running: true command: ["mkdir", Quickshell.env("XDG_RUNTIME_DIR") + "/qs-boot-lock"] onExited: code => { if (code === 0) lock.locked = true; } } PamContext { id: pam // NixOS ships a "login" pam service; pam_unix verifies the // user's own password via the unix_chkpwd setuid wrapper. config: "login" onPamMessage: { if (this.responseRequired) this.respond(root.password); } onCompleted: result => { root.password = ""; if (result === PamResult.Success) lock.locked = false; else root.failed = true; } } WlSessionLock { id: lock WlSessionLockSurface { id: surface color: "black" Image { id: wall anchors.fill: parent source: "file://" + Quickshell.env("HOME") + "/.local/share/backgrounds/wallpaper.png" // cache:false — soft reload (qs ipc reload) keeps the process // alive, and the QML pixmap cache is keyed by URL, so a new // wallpaper behind the same path never shows without this. cache: false fillMode: Image.PreserveAspectCrop visible: false } FastBlur { anchors.fill: wall source: wall radius: 48 } Rectangle { anchors.fill: parent; color: "black"; opacity: 0.4 } SystemClock { id: lockClock precision: SystemClock.Minutes } Column { anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: -60 spacing: 8 Text { anchors.horizontalCenter: parent.horizontalCenter text: lockClock.date.toLocaleTimeString(Qt.locale(), "HH:mm") color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 72 font.weight: Font.Medium } Text { anchors.horizontalCenter: parent.horizontalCenter text: lockClock.date.toLocaleDateString(Qt.locale(), "dddd, d MMMM") color: Theme.base04 font.family: Theme.fontFamily font.pixelSize: 16 } Item { width: 1; height: 32 } // Password pill: dots for typed chars; border tracks // state (accent while checking, red on failure). Rectangle { anchors.horizontalCenter: parent.horizontalCenter width: 280 height: 44 radius: Theme.radiusCard color: Theme.cardBg border.width: Theme.borderWidth border.color: pam.active ? Theme.base0D : root.failed ? Theme.base08 : Theme.base03 Behavior on border.color { ColorAnimation { duration: Theme.animFade } } Row { anchors.centerIn: parent spacing: 8 Repeater { model: Math.min(pwInput.text.length, 24) Rectangle { width: 8; height: 8; radius: 4 color: Theme.base05 } } } Text { anchors.centerIn: parent visible: pwInput.text === "" && root.failed text: "wrong password" color: Theme.base08 font.family: Theme.fontFamily font.pixelSize: 13 } TextInput { id: pwInput anchors.fill: parent opacity: 0 enabled: !pam.active echoMode: TextInput.Password focus: true function go() { root.submit(text); text = ""; } // Explicit key handlers: accepted() doesn't // fire reliably on the lock surface. Keys.onReturnPressed: go() Keys.onEnterPressed: go() onAccepted: go() Keys.onEscapePressed: text = "" } // Visible confirm button (after TextInput: on top) Rectangle { anchors.right: parent.right anchors.rightMargin: 6 anchors.verticalCenter: parent.verticalCenter width: 32; height: 32; radius: 16 color: confirmMa.containsMouse ? Theme.base02 : "transparent" Behavior on color { ColorAnimation { duration: Theme.animFade } } visible: pwInput.text.length > 0 Text { anchors.centerIn: parent text: "arrow_forward" font.family: Theme.iconFont font.pixelSize: 18 color: Theme.base0D } MouseArea { id: confirmMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: pwInput.go() } } } } Component.onCompleted: pwInput.forceActiveFocus() } } } ''; }; "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 systemctl: "${pkgs.systemd}/bin/systemctl" readonly property string weatherFetch: "${weatherFetchScript}" readonly property string ssh: "${pkgs.openssh}/bin/ssh" readonly property string novaAnc: "${novaAncScript}" // Unprivileged ping works via ICMP dgram sockets — NixOS sets // net.ipv4.ping_group_range for everyone (no wrapper exists). readonly property string ping: "${pkgs.iputils}/bin/ping" // Recoloured to the theme accent at draw time (ColorOverlay), // so the stock blue/grey snowflake artwork is fine here. readonly property string nixLogo: "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg" } ''; }; "quickshell/shell.qml" = { onChange = qsRestart; text = '' //@ pragma UseQApplication import Quickshell import Quickshell.Io import Quickshell.Wayland 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); } } // Session lock (replaces hyprlock). `qs ipc call lock lock` // from hypridle / loginctl lock-session; the session menu // calls root.lock() directly. Idempotent while locked. Lock { id: lockScreen } function lock() { lockScreen.locked = true; } IpcHandler { target: "lock" function lock(): void { root.lock(); } // Escape hatch from a VT: qs ipc call lock unlock function unlock(): void { lockScreen.locked = false; } } // Screenshot pin: the Shift+Super+S keybind brackets hyprshot // with pin/unpin so the focus grab is suspended while slurp // grabs input — otherwise the open menu would close like any // click-outside. Self-heals via a watchdog if unpin is missed. IpcHandler { target: "screenshot" function pin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(true); } function unpin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(false); } } // ── OSD brightness (replaces swayosd). Volume needs nothing // here — the bar watches Pipewire directly. Backlight has no // watchable change event, so the keybind routes through here: // brightnessctl -m prints the resulting level, which becomes // the OSD value. Bars pick this up on the focused monitor. signal osdBrightness(string icon, real value) Process { id: brightProc property string osdIcon: "brightness_6" stdout: StdioCollector { // machine-readable: device,class,current,percent%,max onStreamFinished: { let f = text.trim().split("\n")[0].split(","); if (f.length < 5) return; root.osdBrightness(brightProc.osdIcon, parseInt(f[3]) / 100); } } } function setBrightness(args, icon) { // Key repeat outpaces the process; dropping a step is // invisible, rewriting a live command is not. if (brightProc.running) return; brightProc.osdIcon = icon; brightProc.command = ["brightnessctl", "-m"].concat(args); brightProc.running = true; } IpcHandler { target: "osd" // floor 1%: `set 0%` turns the panel fully off function brightnessUp(): void { root.setBrightness(["set", "5%+"], "brightness_6"); } function brightnessDown(): void { root.setBrightness(["-n", "1", "set", "5%-"], "brightness_6"); } function kbdUp(): void { root.setBrightness(["-d", "smc::kbd_backlight", "set", "10%+"], "keyboard"); } function kbdDown(): void { root.setBrightness(["-d", "smc::kbd_backlight", "set", "10%-"], "keyboard"); } } // ── Night light: quickshell owns the schedule, the hyprsunset // daemon just applies the gamma (hyprctl hyprsunset …). // sunrise/sunset default to 7:00/21:00 and are replaced with // real times by the bar's open-meteo weather fetch. property int sunriseMins: 7 * 60 property int sunsetMins: 21 * 60 readonly property var nightlight: nlState FileView { id: nlFile path: Quickshell.env("HOME") + "/.local/state/quickshell-nightlight.json" printErrors: false adapter: JsonAdapter { id: nlState property bool autoOn: true property real warmth: 0.5 // 0 = 6500K … 1 = 2500K } onLoaded: root.applyNightlight() onLoadFailed: writeAdapter() // first run: create with defaults } function setNightlight(autoOn, warmth) { nlState.autoOn = autoOn; nlState.warmth = warmth; applyNightlight(); nlFile.writeAdapter(); } function applyNightlight() { let w = nlState.warmth; if (nlState.autoOn) { let now = new Date(); let mins = now.getHours() * 60 + now.getMinutes(); if (mins >= sunriseMins && mins < sunsetMins) w = 0; // daytime } nlApply.command = w < 0.02 ? ["hyprctl", "hyprsunset", "identity"] : ["hyprctl", "hyprsunset", "temperature", String(Math.round(6500 - w * 4000))]; nlApply.running = true; } Process { id: nlApply } Timer { // Re-assert once a minute: crosses the sunrise/sunset // boundaries and heals a hyprsunset daemon that (re)started // after us. interval: 60000; running: true; repeat: true; triggeredOnStart: true onTriggered: root.applyNightlight() } NotificationServer { id: _notifServer bodySupported: true actionsSupported: true imageSupported: true persistenceSupported: true keepOnReload: true onNotification: (notification) => { notification.tracked = true; root.latestNotification = notification; root.notificationReceived(); } } // Wallpaper — replaces the hyprpaper daemon. Variants { model: Quickshell.screens PanelWindow { required property var modelData screen: modelData WlrLayershell.layer: WlrLayer.Background exclusionMode: ExclusionMode.Ignore anchors { top: true; bottom: true; left: true; right: true } color: "black" Image { anchors.fill: parent source: "file://" + Quickshell.env("HOME") + "/.local/share/backgrounds/wallpaper.png" cache: false // same-URL pixmap cache survives soft reload fillMode: Image.PreserveAspectCrop } } } 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 QtQml.Models 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: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None // Vertical bar on the left edge. The surface still covers the // whole screen (the frame band and every panel are drawn into // it); only the reserved strip is the bar itself. anchors { top: true left: true bottom: true } implicitWidth: bar.screen.width exclusiveZone: Theme.barWidth 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: 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.bottom: parent.bottom width: Theme.barWidth } // 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() { bar.toggleDropdown(sessionMenu, function() { let pos = powerWidget.mapToItem(bar.contentItem, 0, powerWidget.height / 2); sessionMenu.dropdownY = pos.y; }); } 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 } // ── BarSep: hairline between widget groups in the bar column. // Wrapped in a plain Item because a Column refuses to position // children that set their own horizontalCenter anchor. component BarSep: Item { width: Theme.barWidth height: 1 Rectangle { anchors.centerIn: parent width: 16 height: 1 color: Theme.base02 } } // ── GlanceStat: a bar-column stat — clock-sized value over a // faded 9px caption (the server glance rows). component GlanceStat: Column { property alias value: _gsV.text property alias caption: _gsC.text property color valueColor: Theme.base05 anchors.horizontalCenter: parent.horizontalCenter spacing: 0 SText { id: _gsV anchors.horizontalCenter: parent.horizontalCenter font.pixelSize: 15 font.weight: Font.Medium color: parent.valueColor } SText { id: _gsC anchors.horizontalCenter: parent.horizontalCenter font.pixelSize: 9 color: Theme.base03 } } // ── VolIcon: a slider's volume glyph that toggles its audio // node's mute on click. Glyph reflects the mute state; pair it // with a fill that greys when `audioNode.muted`. component VolIcon: SIcon { property var audioNode: null text: (audioNode && audioNode.muted) ? "volume_off" : "volume_up" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: if (audioNode) audioNode.muted = !audioNode.muted } } // ── HoverRow: a rounded clickable row that owns the shared // base02 hover-fade + pointer cursor. Drop content inside and // handle `onClicked`; override `radius` for non-radiusTiny rows. component HoverRow: Rectangle { default property alias rowData: _hrContent.data signal clicked() radius: Theme.radiusTiny color: _hrMa.containsMouse ? Theme.base02 : Theme.base02t Behavior on color { ColorAnimation { duration: Theme.animFade } } Item { id: _hrContent; anchors.fill: parent } MouseArea { id: _hrMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: parent.clicked() } } // ── 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.radiusCard color: Theme.cardBg 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: rounded track + fill + knob with a full-height // invisible hit area. `value` is 0..1; `moved(v)` fires on drag. // Knob diameter derives from the track, so the small app-stream // variants scale with just a trackH override. component PillSlider: Item { property real value: 0 property color fillColor: Theme.base0D property real trackH: 8 readonly property real knobD: trackH + 8 signal moved(real v) height: 22 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 } } } Rectangle { width: parent.knobD height: parent.knobD radius: parent.knobD / 2 anchors.verticalCenter: parent.verticalCenter x: Math.max(0, Math.min(parent.width - parent.knobD, parent.value * parent.width - parent.knobD / 2)) color: Theme.base06 Behavior on x { 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); } } } // ── ToggleSwitch: small on/off pill. component ToggleSwitch: Rectangle { property bool on: false signal toggled(bool v) width: 34; height: 18; radius: 9 color: on ? Theme.base0D : Theme.base02 Behavior on color { ColorAnimation { duration: Theme.animFade } } Rectangle { width: 12; height: 12; radius: 6 anchors.verticalCenter: parent.verticalCenter x: parent.on ? parent.width - width - 3 : 3 color: Theme.base05 Behavior on x { NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutExpo } } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: parent.toggled(!parent.on) } } // ── NightlightCard: colour-temperature control backed by // shell.qml's night-light state. Auto on: follows // sunrise/sunset and the slider sets the *warmest* colour the // night reaches. Auto off: the slider is the temperature, now. component NightlightCard: Card { id: _nlc cardSpacing: 6 property var nl: bar.shellRoot.nightlight Item { width: parent.width height: 20 Row { spacing: 6 anchors.verticalCenter: parent.verticalCenter SIcon { anchors.verticalCenter: parent.verticalCenter; text: "nightlight"; font.pixelSize: 15 } SText { anchors.verticalCenter: parent.verticalCenter; text: "Night Light"; font.weight: Font.Medium } } Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 6 SText { anchors.verticalCenter: parent.verticalCenter; text: "Auto"; color: Theme.base04; font.pixelSize: 11 } ToggleSwitch { anchors.verticalCenter: parent.verticalCenter on: _nlc.nl.autoOn onToggled: (v) => bar.shellRoot.setNightlight(v, _nlc.nl.warmth) } } } SText { text: _nlc.nl.autoOn ? "Warmest colour (at night)" : "Colour temperature" color: Theme.base04 font.pixelSize: 10 } Row { width: parent.width spacing: 8 PillSlider { width: parent.width - nlTempLabel.width - 8 anchors.verticalCenter: parent.verticalCenter value: _nlc.nl.warmth fillColor: Theme.base09 onMoved: (v) => bar.shellRoot.setNightlight(_nlc.nl.autoOn, v) } SText { id: nlTempLabel width: 38 text: Math.round(6500 - _nlc.nl.warmth * 4000) + "K" font.pixelSize: 11 horizontalAlignment: Text.AlignRight anchors.verticalCenter: parent.verticalCenter } } } // ── 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 // App header: icon + name of the sending app, when known. Row { width: parent.width spacing: 4 visible: _nc.notif && (_nc.notif.appName !== "" || _appIcon.source != "") Image { id: _appIcon width: 14; height: 14 sourceSize.width: 14; sourceSize.height: 14 anchors.verticalCenter: parent.verticalCenter source: _nc.notif && _nc.notif.appIcon ? Quickshell.iconPath(_nc.notif.appIcon, true) : "" visible: source != "" } SText { anchors.verticalCenter: parent.verticalCenter text: _nc.notif ? _nc.notif.appName : "" color: Theme.base04 font.pixelSize: 10 elide: Text.ElideRight width: parent.width - (_appIcon.visible ? 18 : 0) } } 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 // ponytail: hyprshot's body is a noisy file path; the // "Screenshot saved" summary + the preview say enough. visible: text !== "" && !(_nc.notif && _nc.notif.appName === "Hyprshot") } 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 // Panels grow rightward out of the bar column, so the // cross-axis is now vertical: the 8px inset that used to // be left/right is top/bottom. readonly property real rawTop: chrome.y + 8 readonly property real rawBottom: chrome.y + chrome.height - 8 // How deep a surface buries itself in the band it merges // with. This MUST be >= the panel corner radius. A rounded // corner curves away from the edge over exactly panelR // pixels, so burying less than that leaves part of the // curve sticking out past the joint: the outline pinches in // where the corner turns, then the fillet bulges back out — // the "ballooned at the connection point" look. Bury the // whole corner and only straight edges reach the junction, // which is what smin is meant to fillet. readonly property real meltInto: Theme.radius readonly property real meltTop: Theme.frameWidth - meltInto readonly property real meltBot: bar.height - Theme.frameWidth + meltInto // Trigger distance is about proximity, not burial depth. readonly property real snapDist: Theme.panelGap + 4 readonly property real panelTop: rawTop <= Theme.frameWidth + snapDist ? meltTop : rawTop readonly property real panelBottom: rawBottom >= bar.height - Theme.frameWidth - snapDist ? meltBot : rawBottom // Left edge lands at (barWidth − meltInto): the surface // buries a full corner radius inside the bar column. readonly property real surfLeftX: Theme.barWidth - meltInto / 2 property vector4d cutout: Qt.vector4d( (Theme.barWidth + bar.width - Theme.frameWidth) / 2, bar.height / 2, (bar.width - Theme.frameWidth - Theme.barWidth) / 2, bar.height / 2 - Theme.frameWidth) property vector4d panel: chrome.visible ? Qt.vector4d(surfLeftX + chrome.width / 2, (panelTop + panelBottom) / 2, meltInto / 2 + chrome.width / 2, (panelBottom - panelTop) / 2) : Qt.vector4d(0, 0, 0, 0) // Toast sits under the top frame band and melts up into it. readonly property real toastBot: toastItem.y + 8 + _toastRect.height property vector4d toast: toastItem.visible && _toastRect.width > 0.5 ? Qt.vector4d(surfLeftX + _toastRect.width / 2, (meltTop + toastBot) / 2, meltInto / 2 + _toastRect.width / 2, (toastBot - meltTop) / 2) : Qt.vector4d(0, 0, 0, 0) // The launcher is the one surface that stays put: it still // grows from the bottom frame band at screen centre. readonly property real launchBot: bar.height - Theme.frameWidth + meltInto 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 // Half the corner radius: the fillet scales with the // one rounding knob instead of drifting out of step. property real meltK: Theme.radius / 2 property real borderW: Theme.borderWidth fragmentShader: "file://${chromeShader}" } // ── 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. Opens on a category // browser; typing switches to fuzzy search. Results live in a // ListModel that's DIFFED against each new filter pass, so // surviving rows slide to their new rank (ListView move // transitions) instead of the whole list repopulating. Item { id: launcherPanel property bool open: false readonly property real panelW: 520 // Cap the visible list at 8 rows; longer lists scroll. readonly property real listCapH: 8 * 56 property real targetH: launcherCol.height + 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 property string activeCat: "" property var entries: [] readonly property var catDefs: [ { key: "All", label: "All apps", icon: "apps" }, { key: "Network", label: "Internet", icon: "public" }, { key: "Development", label: "Development", icon: "code" }, { key: "Game", label: "Games", icon: "sports_esports" }, { key: "AudioVideo", label: "Media", icon: "play_circle" }, { key: "Graphics", label: "Graphics", icon: "palette" }, { key: "Office", label: "Office", icon: "description" }, { key: "System", label: "System", icon: "monitor" }, { key: "Utility", label: "Utilities", icon: "widgets" }, { key: "Other", label: "Other", icon: "more_horiz" } ] function inCat(a, key) { if (key === "All") return true; const c = a.categories; if (key === "System") return c.indexOf("System") !== -1 || c.indexOf("Settings") !== -1; if (key === "Other") { const main = ["Network", "Development", "Game", "AudioVideo", "Graphics", "Office", "System", "Settings", "Utility"]; for (let i = 0; i < main.length; i++) if (c.indexOf(main[i]) !== -1) return false; return true; } return c.indexOf(key) !== -1; } function catCount(key) { return DesktopEntries.applications.values.filter(a => !a.noDisplay && inCat(a, key)).length; } function catLabel(key) { const d = catDefs.find(x => x.key === key); return d ? d.label : key; } function toggle() { open = !open; if (open) { searchInput.text = ""; activeCat = ""; refilter(); searchInput.forceActiveFocus(); } } function activate(item) { if (!item) return; item.execute(); open = false; } function refilter() { let q = searchInput.text.toLowerCase().trim(); let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay); let list; if (q === "") { if (activeCat === "") { list = []; } else { list = apps.filter(a => inCat(a, activeCat)); list.sort((a, b) => a.name.localeCompare(b.name)); } } else { 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)); list = scored.slice(0, 12).map(x => x.app); } entries = list; syncModel(list); launcherList.currentIndex = list.length > 0 ? 0 : -1; } // Diff the new result list into the ListModel instead of // resetting it: surviving rows MOVE (animated), dropped // rows fade out, new rows fade in. A plain array model // would rebuild every delegate on each keystroke. function syncModel(list) { const m = launcherModel; const ids = {}; for (let i = 0; i < list.length; i++) ids[list[i].id] = true; for (let i = m.count - 1; i >= 0; i--) if (!ids[m.get(i).appId]) m.remove(i); for (let i = 0; i < list.length; i++) { const a = list[i]; let j = -1; for (let k = i; k < m.count; k++) if (m.get(k).appId === a.id) { j = k; break; } if (j === -1) { m.insert(i, { appId: a.id, name: a.name, icon: Quickshell.iconPath(a.icon, true) || "", desc: a.genericName !== "" ? a.genericName : a.comment }); } else if (j !== i) { m.move(j, i, 1); } } while (m.count > list.length) m.remove(m.count - 1); } ListModel { id: launcherModel } 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; } // 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 { id: launcherCol anchors.bottom: parent.bottom anchors.bottomMargin: 12 anchors.horizontalCenter: parent.horizontalCenter width: launcherPanel.panelW - 24 spacing: 8 // Category-browse breadcrumb: back to the grid. HoverRow { width: parent.width height: 30 visible: launcherPanel.activeCat !== "" && searchInput.text === "" onClicked: { launcherPanel.activeCat = ""; launcherPanel.refilter(); } Row { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 10 spacing: 8 SIcon { anchors.verticalCenter: parent.verticalCenter text: "arrow_back" font.pixelSize: 16 color: Theme.base04 } SText { anchors.verticalCenter: parent.verticalCenter text: launcherPanel.catLabel(launcherPanel.activeCat) font.weight: Font.Medium } SText { anchors.verticalCenter: parent.verticalCenter text: launcherPanel.entries.length + " apps" font.pixelSize: 11 color: Theme.base04 } } } // Results sit in a base01 card segment, like the // notification list and the other dropdowns. Rectangle { width: parent.width height: Math.min(launcherList.contentHeight, launcherPanel.listCapH) + 2 * Theme.cardPad radius: Theme.radiusCard color: Theme.cardBg visible: launcherList.count > 0 ListView { id: launcherList anchors.fill: parent anchors.margins: Theme.cardPad clip: true interactive: contentHeight > height model: launcherModel highlight: Rectangle { radius: Theme.radiusSmall color: Theme.base02 } highlightMoveDuration: 200 highlightMoveVelocity: -1 highlightResizeDuration: 0 move: Transition { NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo } } displaced: Transition { NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo } } add: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.animContent } NumberAnimation { property: "scale"; from: 0.92; to: 1; duration: Theme.animContent; easing.type: Easing.OutExpo } } remove: Transition { NumberAnimation { property: "opacity"; to: 0; duration: 100 } } delegate: Item { required property string appId required property string name required property string icon required property string desc required property int index width: launcherList.width height: 56 Row { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 12 spacing: 12 Image { visible: source != "" anchors.verticalCenter: parent.verticalCenter width: 32 height: 32 sourceSize.width: 32 sourceSize.height: 32 source: icon } Column { anchors.verticalCenter: parent.verticalCenter width: launcherList.width - 68 spacing: 1 SText { width: parent.width text: name font.pixelSize: 14 font.weight: Font.Medium elide: Text.ElideRight } SText { width: parent.width visible: text !== "" text: desc font.pixelSize: 11 color: Theme.base04 elide: Text.ElideRight } } } MouseArea { anchors.fill: parent hoverEnabled: true onEntered: launcherList.currentIndex = index onClicked: launcherPanel.activate(launcherPanel.entries[index]) } } } } // Category grid: the browse view when nothing is // typed and no category is active. Rectangle { width: parent.width height: catGrid.height + 2 * Theme.cardPad radius: Theme.radiusCard color: Theme.cardBg visible: searchInput.text === "" && launcherPanel.activeCat === "" Grid { id: catGrid anchors.top: parent.top anchors.topMargin: Theme.cardPad anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 2 * Theme.cardPad columns: 2 columnSpacing: 8 rowSpacing: 8 Repeater { model: launcherPanel.catDefs HoverRow { required property var modelData width: (catGrid.width - catGrid.columnSpacing) / 2 height: 48 onClicked: { launcherPanel.activeCat = modelData.key; launcherPanel.refilter(); } Row { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 12 spacing: 10 SIcon { anchors.verticalCenter: parent.verticalCenter text: modelData.icon font.pixelSize: 20 color: Theme.base0D } Column { anchors.verticalCenter: parent.verticalCenter SText { text: modelData.label font.weight: Font.Medium } SText { text: launcherPanel.catCount(modelData.key) + " apps" font.pixelSize: 10 color: Theme.base04 } } } } } } } Rectangle { width: parent.width height: 44 radius: Theme.radiusCard color: Theme.cardBg SIcon { id: searchIcon anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "search" color: Theme.base04 font.pixelSize: 18 } TextInput { id: searchInput anchors.left: searchIcon.right anchors.leftMargin: 10 anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter color: Theme.base05 font.family: Theme.fontFamily font.pixelSize: 14 clip: true onTextChanged: launcherPanel.refilter() 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: 10 anchors.verticalCenter: parent.verticalCenter visible: searchInput.text === "" text: launcherPanel.activeCat === "" ? "Search apps" : "Search all apps" color: Theme.base03 font.pixelSize: 14 } } } } } // Click-outside dismissal for every panel — the launcher, // session menu AND the bar dropdowns. Clicking into another // window (or anywhere outside the bar) clears the grab and // closes whatever is open. Suspended while screenshotPinned so // slurp can grab input without dismissing the menu. HyprlandFocusGrab { active: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned windows: [bar] onCleared: { if (bar.screenshotPinned) return; launcherPanel.open = false; bar.closeAllDropdowns(); } } property var activeDropdown: null // Set by the screenshot keybind (via IPC) to hold menus open // while a region screenshot runs. Watchdog unpins if the // bracketing unpin call is ever missed. property bool screenshotPinned: false Timer { id: _pinWatchdog interval: 30000 onTriggered: bar.screenshotPinned = false } function setScreenshotPin(v) { screenshotPinned = v; if (v) _pinWatchdog.restart(); else _pinWatchdog.stop(); } function closeAllDropdowns() { if (activeDropdown && activeDropdown.visible) { activeDropdown.animateClose(); } } // Preview image for a notification: its image hint, else a // file-path appIcon (e.g. hyprshot's saved shot). "" = none. function notifPreviewSource(notif) { if (!notif) return ""; if (notif.image && notif.image !== "") return notif.image; let a = notif.appIcon || ""; if (a.startsWith("file://")) return a; if (a.startsWith("/")) return "file://" + a; return ""; } 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). // width, not height: the growth axis is horizontal now. // chrome.height is tH, which is never 0, so testing it // meant this never fired and the panel never seeded. if (!activeDropdown && chrome.width < 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; } } } // SystemClock ticks on the minute boundary instead of a 1 Hz // Timer; the calendar reads clockText.now too. SystemClock { id: sysClock precision: SystemClock.Minutes } // ── Top cluster: logo, clock, workspaces — the three groups // that belong to "this machine, right now", split by hairlines. // The server glance is NOT here: it anchors above the bottom // cluster instead, so adding a workspace grows this column // downward into empty space rather than shoving the stats. Column { id: barTop anchors.top: parent.top anchors.topMargin: 12 anchors.horizontalCenter: barBgRect.horizontalCenter spacing: 12 // NixOS snowflake, flattened to the theme accent with the // same hidden-Image + ColorOverlay trick the tray uses // (there is no Material Symbols glyph for it). Item { width: Theme.barWidth height: 24 Image { id: nixLogoImg anchors.centerIn: parent width: 22 height: 22 source: "file://" + Commands.nixLogo sourceSize.width: 22 sourceSize.height: 22 smooth: true visible: false } ColorOverlay { anchors.fill: nixLogoImg source: nixLogoImg color: Theme.base0D } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: bar.toggleLauncher() } } BarSep {} // Clock — HH over mm; "HH:mm" does not fit a 46px column. Item { id: clockText width: Theme.barWidth height: clockCol.height property date now: sysClock.date Column { id: clockCol anchors.horizontalCenter: parent.horizontalCenter spacing: 0 SText { anchors.horizontalCenter: parent.horizontalCenter text: clockText.now.toLocaleTimeString(Qt.locale(), "HH") font.pixelSize: 15 font.weight: Font.Medium } SText { anchors.horizontalCenter: parent.horizontalCenter text: clockText.now.toLocaleTimeString(Qt.locale(), "mm") font.pixelSize: 15 font.weight: Font.Medium color: Theme.base04 } } // Anchors to the clock like every other dropdown // anchors to its widget; the clamp in BarDropdown // pulls it back on screen when it overhangs. function openCalendar() { bar.toggleDropdown(calPopup, function() { let pos = clockText.mapToItem(bar.contentItem, 0, clockText.height / 2); calPopup.dropdownY = pos.y; calPopup.resetView(); }); } MouseArea { anchors.fill: parent hoverEnabled: true onClicked: clockText.openCalendar() onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== calPopup) clockText.openCalendar(); } } } } BarSep {} // Workspace dots: accent pill for the focused workspace, // dim dots otherwise. Stacked vertically now, so the // focused pill grows along HEIGHT rather than width. Column { spacing: 4 Repeater { model: Hyprland.workspaces Item { id: wsItem required property var modelData visible: modelData.id > 0 width: Theme.barWidth height: visible ? dot.height + 6 : 0 Rectangle { id: dot anchors.centerIn: parent width: 6 height: wsItem.modelData.focused ? 18 : 6 radius: 3 color: wsItem.modelData.focused ? Theme.base0D : wsMa.containsMouse ? Theme.base04 : Theme.base03 Behavior on height { 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() } } } } } // ── Mid cluster: the server glance, pinned just above the // bottom cluster. Its own anchor (not a barTop child) is the // whole point: workspace count then changes nothing here. // // Server monitor: one persistent SSH streaming `top -b` // from the mediaserver (2s ticks, parsed line by line — // no per-poll reconnects) plus a streaming ping to the // router. Glance = CPU/RAM numbers + ping ms; click // opens the btop-style dropdown. Item { id: srvWidget width: Theme.barWidth height: srvGlanceCol.height anchors.horizontalCenter: barBgRect.horizontalCenter anchors.bottom: barBottom.top anchors.bottomMargin: 20 // Fades out while the OSD takes this slot. opacity: osdItem.shown ? 0 : 1 Behavior on opacity { NumberAnimation { duration: Theme.animContent } } property bool connected: false property real cpu: 0 property real memUsed: 0 // GiB property real memTotal: 0 property real pingMs: -1 property string uptime: "" property string load: "" property var procs: [] property int tempC: -1 property real rxBps: 0 property real txBps: 0 function fmtRate(bps) { const b = bps * 8; if (b >= 1e9) return (b / 1e9).toFixed(2) + " Gb/s"; if (b >= 1e6) return (b / 1e6).toFixed(1) + " Mb/s"; if (b >= 1e3) return Math.round(b / 1e3) + " Kb/s"; return Math.round(b) + " b/s"; } // Split variants for the bar glance: number and unit // live on separate lines, so both stay narrow. function rateVal(bps) { const b = bps * 8; if (b >= 1e9) return (b / 1e9).toFixed(1); if (b >= 1e7) return Math.round(b / 1e6).toString(); if (b >= 1e6) return (b / 1e6).toFixed(1); return Math.round(b / 1e3).toString(); } function rateUnit(bps) { const b = bps * 8; if (b >= 1e9) return "gb/s"; if (b >= 1e6) return "mb/s"; return "kb/s"; } property var _procsPending: [] property bool _collecting: false function _parseLine(line) { if (line.indexOf("@STAT ") === 0) { const t = line.match(/temp=(\d+)/); if (t) tempC = parseInt(t[1]); const rx = line.match(/rxbps=(-?\d+)/); if (rx) rxBps = Math.max(0, parseInt(rx[1])); const tx = line.match(/txbps=(-?\d+)/); if (tx) txBps = Math.max(0, parseInt(tx[1])); return; } if (line.indexOf("top - ") === 0) { if (_collecting) procs = _procsPending; _collecting = false; const up = line.match(/up\s+(.*?),\s*\d+\s+users?,/); if (up) uptime = up[1].replace(/\s+/g, " "); const la = line.match(/load average:\s*(.*)$/); if (la) load = la[1]; return; } if (line.indexOf("%Cpu(s)") === 0) { const m = line.match(/([\d.]+)\s+id/); if (m) { cpu = Math.max(0, 100 - parseFloat(m[1])); connected = true; } return; } const mem = line.match(/^(\S+) Mem\s*:\s*([\d.]+)\s+total,\s*[\d.]+\s+free,\s*([\d.]+)\s+used/); if (mem) { const div = mem[1] === "KiB" ? 1048576 : mem[1] === "MiB" ? 1024 : 1; memTotal = parseFloat(mem[2]) / div; memUsed = parseFloat(mem[3]) / div; return; } if (/^\s*PID\s/.test(line)) { _collecting = true; _procsPending = []; return; } if (_collecting) { const f = line.trim().split(/\s+/); if (f.length >= 12) { _procsPending.push({ pid: f[0], cpu: parseFloat(f[8]), mem: parseFloat(f[9]), name: f.slice(11).join(" ") }); // top sorts by %CPU; first 10 rows are the story if (_procsPending.length >= 10) { procs = _procsPending; _collecting = false; } } else { procs = _procsPending; _collecting = false; } } } Process { id: srvTop command: [Commands.ssh, "-o", "BatchMode=yes", "-o", "ConnectTimeout=3", "-o", "StrictHostKeyChecking=accept-new", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", "fred@10.0.0.1", "qs-stats"] running: true stdout: SplitParser { onRead: data => srvWidget._parseLine(data) } onRunningChanged: if (!running) { srvWidget.connected = false; srvRestart.start(); } } Timer { id: srvRestart; interval: 10000; onTriggered: srvTop.running = true } // Ping is deliberately separate from the SSH stream: // ping up + ssh down = box alive but wedged (seen it). Process { id: srvPing command: [Commands.ping, "-n", "-i", "2", "10.0.0.1"] running: true stdout: SplitParser { onRead: data => { const m = data.match(/time=([\d.]+)\s*ms/); if (m) { srvWidget.pingMs = parseFloat(m[1]); pingStale.restart(); } } } onRunningChanged: if (!running) pingRestart.start() } Timer { id: pingRestart; interval: 10000; onTriggered: srvPing.running = true } // No echo reply for 3 intervals -> show dashes, not a stale number. Timer { id: pingStale; interval: 6000; onTriggered: srvWidget.pingMs = -1 } function openSrvDropdown() { bar.toggleDropdown(srvDropdown, function() { let pos = srvWidget.mapToItem(bar.contentItem, 0, srvWidget.height / 2); srvDropdown.dropdownY = pos.y; }); } // Stat stack, clock-sized values over faded captions. Column { id: srvGlanceCol anchors.centerIn: parent spacing: 5 GlanceStat { value: srvWidget.connected ? Math.round(srvWidget.cpu) + "%" : "--" caption: "cpu" valueColor: srvWidget.connected ? Theme.base0D : Theme.base03 } GlanceStat { value: srvWidget.connected && srvWidget.memTotal > 0 ? Math.round(100 * srvWidget.memUsed / srvWidget.memTotal) + "%" : "--" caption: "ram" valueColor: srvWidget.connected ? Theme.base0C : Theme.base03 } GlanceStat { value: srvWidget.connected && srvWidget.tempC >= 0 ? srvWidget.tempC + "°" : "--" caption: "temp" valueColor: !srvWidget.connected || srvWidget.tempC < 0 ? Theme.base03 : srvWidget.tempC >= 85 ? Theme.base08 : srvWidget.tempC >= 70 ? Theme.base0A : Theme.base05 } GlanceStat { value: srvWidget.connected ? srvWidget.rateVal(srvWidget.rxBps) : "--" caption: "↓ " + srvWidget.rateUnit(srvWidget.rxBps) valueColor: srvWidget.connected ? Theme.base0B : Theme.base03 } GlanceStat { value: srvWidget.connected ? srvWidget.rateVal(srvWidget.txBps) : "--" caption: "↑ " + srvWidget.rateUnit(srvWidget.txBps) valueColor: srvWidget.connected ? Theme.base09 : Theme.base03 } GlanceStat { value: srvWidget.pingMs < 0 ? "--" : srvWidget.pingMs < 10 ? srvWidget.pingMs.toFixed(1) : Math.round(srvWidget.pingMs).toString() caption: "ms" valueColor: srvWidget.pingMs < 0 ? Theme.base03 : Theme.base04 } } MouseArea { anchors.fill: parent hoverEnabled: true onClicked: srvWidget.openSrvDropdown() onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== srvDropdown) srvWidget.openSrvDropdown(); } } } } // ── Bottom cluster: status icons, tray, power button. Anchored // to the bottom edge; the server glance sits just above it and // the OSD borrows that same slot while it is on screen. Column { id: barBottom anchors.bottom: parent.bottom anchors.bottomMargin: 12 anchors.horizontalCenter: barBgRect.horizontalCenter spacing: 16 // Volume Item { id: volWidget width: Theme.barWidth height: 26 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, 0, volWidget.height / 2); volDropdown.dropdownY = pos.y; }); } // Icon only — the level lives in the OSD slot above and // in the dropdown; "100%" does not fit the column. SIcon { anchors.centerIn: parent text: volWidget.volIcon color: volWidget.muted ? Theme.base03 : Theme.base05 font.pixelSize: 18 } 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(); } } } } // Network status Item { id: netWidget width: Theme.barWidth height: 26 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, 0, netWidget.height / 2); netDropdown.dropdownY = pos.y; }); } MouseArea { anchors.fill: parent hoverEnabled: true onClicked: netWidget.openNetDropdown() onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown(); } } } } ${lib.optionalString isMacbook '' // Battery Item { id: batteryWidget width: Theme.barWidth height: batteryCol.height // 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" // Stacked: icon over percentage. Side by side no longer // fits, but the level is worth keeping on a laptop. Column { id: batteryCol anchors.horizontalCenter: parent.horizontalCenter spacing: 0 SIcon { id: batteryIconText anchors.horizontalCenter: parent.horizontalCenter text: batteryWidget.batteryIcon color: batteryWidget.batteryLevel <= 15 ? Theme.base08 : batteryWidget.batteryLevel <= 30 ? Theme.base0A : Theme.base05 font.pixelSize: 18 } SText { id: batteryText anchors.horizontalCenter: parent.horizontalCenter text: batteryWidget.batteryLevel color: batteryWidget.batteryLevel <= 15 ? Theme.base08 : batteryWidget.batteryLevel <= 30 ? Theme.base0A : Theme.base05 font.pixelSize: 10 } } function openBatteryDropdown() { bar.toggleDropdown(batteryDropdown, function() { let pos = batteryWidget.mapToItem(bar.contentItem, 0, batteryWidget.height / 2); batteryDropdown.dropdownY = pos.y; brightRead.running = true; // refresh sliders on open }); } MouseArea { anchors.fill: parent hoverEnabled: true onClicked: batteryWidget.openBatteryDropdown() onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown(); } } } } ''} ${lib.optionalString (!isMacbook) '' // Quick settings (night light) — no battery widget on the // desktop, so the card gets its own bar icon. Item { id: qsWidget width: Theme.barWidth height: 26 SIcon { id: qsIcon // centerIn, not verticalCenter: the cell used to be // qsIcon.width so horizontal centring was implicit. anchors.centerIn: parent text: "nightlight" font.pixelSize: 18 } function openQsDropdown() { bar.toggleDropdown(qsDropdown, function() { let pos = qsWidget.mapToItem(bar.contentItem, 0, qsWidget.height / 2); qsDropdown.dropdownY = pos.y; }); } MouseArea { anchors.fill: parent hoverEnabled: true onClicked: qsWidget.openQsDropdown() onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== qsDropdown) qsWidget.openQsDropdown(); } } } } ''} // Group separator: status icons / tray / power read // as three clusters instead of one clump. BarSep {} // Tray icons — stacked, like everything else in the column Column { id: trayArea spacing: 8 width: Theme.barWidth // NO vertical anchor here: a Column refuses to position // ANY child if one of them sets top/bottom/verticalCenter // /fill/centerIn, and silently piles them all at y=0. Repeater { model: SystemTray.items Item { required property var modelData width: Theme.barWidth height: 24 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 } // The icon's own menu cache: assigned from the // moment the icon exists, so the DBus fetch // (slow for Steam) happens at startup, not // mid-animation. contextMenu just points at // this — switching tray icons never reloads. QsMenuOpener { id: itemOpener menu: modelData.menu } MouseArea { anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.NoButton onEntered: { if (!modelData.hasMenu) return; if (!bar.activeDropdown) return; if (bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData) return; let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2); if (bar.activeDropdown === contextMenu) { // Same dropdown, just switch content contextMenu.dropdownY = pos.y; contextMenu.trayItem = modelData; contextMenu.activeOpener = itemOpener; } else { contextMenu.openFor(modelData, pos.y, itemOpener); } } } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (event) => { if (!modelData.hasMenu) { modelData.activate(); } else if (bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData) { // Clicking the open menu's own icon still closes it contextMenu.animateClose(); } else { let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2); contextMenu.openFor(modelData, pos.y, itemOpener); } } } } } } // Group separator: status icons / tray / power read // as three clusters instead of one clump. BarSep {} // Power menu opener — bottom-left corner. Opens the same // session menu as Super+L, which now unfolds from here. Item { id: powerWidget width: Theme.barWidth height: 26 SIcon { anchors.centerIn: parent text: "power_settings_new" color: powerMa.containsMouse || sessionMenu.open ? Theme.base08 : Theme.base05 font.pixelSize: 18 Behavior on color { ColorAnimation { duration: Theme.animFade } } } MouseArea { id: powerMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: bar.toggleSession() onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== sessionMenu) bar.toggleSession(); } } } } } // 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 // Vertical centre of the widget that owns this dropdown; // the panel tracks it, clamped to the screen. The old // flush-to-far-edge special case is gone — the SDF melt // already makes a clamped panel meet the frame cleanly. property real dropdownY: 0 property real fullWidth: 200 property real fullHeight: 200 property real dropdownWidth: open ? fullWidth : 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); } _closeDelay.start(); } // 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; } // Clamp to the frame's inner edge. A panel that lands here // is MEANT to fuse with the frame band — the shader melts // it in (see meltTop/meltBot). Outer max wins when a panel // is taller than the space (yMax < yMin). readonly property real yMin: Theme.frameWidth readonly property real yMax: bar.height - Theme.frameWidth - height x: Theme.barWidth y: Math.round(Math.max(yMin, Math.min(yMax, dropdownY - height / 2))) width: fullWidth + 4 // +24, not +16: the shader insets the panel 8px top and // bottom, so +16 left the panel edge flush with the content // (0 padding) while the growth axis overhung by 4. The // extra 8 gives 4px above and below, matching the right. height: fullHeight + 24 visible: false onVisibleChanged: { if (visible) { closing = false; open = true; } else { open = false; closing = false; } } Timer { id: _closeDelay interval: 300 onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; } } // 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. Growth axis is now x (rightward); // the clip's height is the chrome's inner cross-axis span. Item { id: _dropdownRect x: 0 y: (chrome.y + 8) - dropdown.y width: Math.min(dropdown.fullWidth, chrome.width) height: Math.max(0, chrome.height - 16) clip: true opacity: dropdown.open ? 1 : 0 Behavior on opacity { NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic } } Item { id: dropdownContent // 12 = panel inset (8) + the 4px breathing room, // keeping the content centred in the taller panel. y: 12 - _dropdownRect.y 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 tY: 0 property real tH: 200 property real tW: 0 property real openW: bar.activeDropdown ? tW : 0 property bool snap: false readonly property real stubH: 32 // Grow-from / shrink-to the widget that owns the dropdown: // the panel opens as a small stub beside the button and // expands rightward; closing retargets back to the stub // while the width collapses. function stubY(dd) { return Math.round(Math.min(bar.height - Theme.frameWidth - stubH, Math.max(Theme.frameWidth, dd.dropdownY - stubH / 2))); } // Snap ONLY long enough to place the stub, then release so // both axes animate out to the target: the panel grows // from a small point on the widget rather than sliding out // at full height. Holding the snap through the grow is // what made it arrive pre-grown vertically. function seedFromButton(dd) { snap = true; tY = stubY(dd); tH = stubH; snap = false; } function shrinkToButton(dd) { tY = stubY(dd); tH = stubH; } x: Theme.barWidth y: tY width: openW height: tH visible: width > 0.5 Binding { target: chrome; property: "tY" value: bar.activeDropdown ? bar.activeDropdown.y : 0 when: bar.activeDropdown !== null restoreMode: Binding.RestoreNone } Binding { target: chrome; property: "tH" value: bar.activeDropdown ? bar.activeDropdown.height : 0 when: bar.activeDropdown !== null restoreMode: Binding.RestoreNone } Binding { target: chrome; property: "tW" value: bar.activeDropdown ? bar.activeDropdown.fullWidth + 4 : 0 when: bar.activeDropdown !== null restoreMode: Binding.RestoreNone } Behavior on tY { enabled: !chrome.snap NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } Behavior on tH { enabled: !chrome.snap NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } Behavior on openW { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } } // Context menu BarDropdown { id: contextMenu property var trayItem: null fullWidth: menuItems.width + 2 * Theme.panelGap // While a freshly assigned menu is still loading, the Card // is empty — and an empty Card is not height 0, it is // 2*cardPad — so the panel would morph DOWN to a stub and // then regrow once the items land, instead of moving // across. Hold the last loaded height until the new items // arrive so the morph stays continuous. The correction is // small, and nil when it is the same menu as last time. // Row metrics, shared by the delegate below and the height // sum — these two must never drift apart. readonly property int menuRowHeight: 28 readonly property int menuSepHeight: 9 // Size the panel from the MODEL, not from the laid-out // Card. The Card only reaches its true height a frame or // more after the model populates — the Repeater has to // build delegates and the Column re-measure — and every // previous attempt here read the Card during that gap, // which is the flash. Summing the rows is exact and // available the instant the data lands, so the height and // its content update in the same binding pass. readonly property real menuContentHeight: { if (!menuModel) return 0; const items = menuModel.values; let h = 0; for (let i = 0; i < items.length; i++) h += items[i].isSeparator ? menuSepHeight : menuRowHeight; return h; } // Hold the previous size across the DBus fetch of a newly // assigned menu (the tray-to-tray path), so the switch // morphs instead of collapsing to empty and regrowing. property real lastMenuHeight: 0 onMenuContentHeightChanged: { if (menuContentHeight > 0) lastMenuHeight = menuContentHeight; } fullHeight: (menuContentHeight > 0 ? menuContentHeight : lastMenuHeight) + 2 * Theme.cardPad + 2 * Theme.panelGap // Which tray item's opener feeds the menu. Every tray icon // owns an always-assigned QsMenuOpener (see the tray // Repeater), fetched when the icon appears — so switching // tray→tray swaps one POPULATED model for another in a // single binding pass. The shared opener this replaces // reloaded over DBus on every reassignment, and no sizing // gate downstream could hide that the model itself went // briefly empty (that gap was the flash). property var activeOpener: null readonly property var menuModel: activeOpener ? activeOpener.children : null // Opens immediately — the item's opener has been loaded // since its icon appeared, so there is nothing to wait on. function openFor(item, y, opener) { dropdownY = y; trayItem = item; activeOpener = opener; bar.toggleDropdown(contextMenu, null); } Card { id: menuItems anchors.centerIn: parent width: 200 cardSpacing: 0 Repeater { model: contextMenu.menuModel Rectangle { required property var modelData width: parent.width height: modelData.isSeparator ? contextMenu.menuSepHeight : contextMenu.menuRowHeight color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled ? Theme.base02 : Theme.base02t 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(); } } } } } } // ── Session menu: icon-only power controls morphing out of // the bar at the bottom-left (Super+L, or the power button). // Keyboard: arrows/Tab move the selection, Enter activates, // Esc closes. BarDropdown { id: sessionMenu fullWidth: 48 + 2 * Theme.panelGap fullHeight: sessionCard.height + 2 * Theme.panelGap property int selIdx: 0 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) { animateClose(); if (act === "lock") bar.shellRoot.lock(); else if (act === "logout") Hyprland.dispatch("hl.dsp.exit()"); else if (act === "reboot") Quickshell.execDetached([Commands.systemctl, "reboot"]); else Quickshell.execDetached([Commands.systemctl, "poweroff"]); } focus: open onOpenChanged: if (open) { selIdx = 0; forceActiveFocus(); } Keys.onEscapePressed: bar.closeAllDropdowns() Keys.onUpPressed: selIdx = (selIdx + actions.length - 1) % actions.length Keys.onDownPressed: selIdx = (selIdx + 1) % actions.length Keys.onTabPressed: selIdx = (selIdx + 1) % actions.length Keys.onReturnPressed: activate(actions[selIdx].act) Keys.onEnterPressed: activate(actions[selIdx].act) // Card backing, matching the other dropdowns. The grow / // clip reveal is BarDropdown's job now. Rectangle { id: sessionCard anchors.centerIn: parent width: 48 height: sessionCol.height + 8 radius: Theme.radiusCard color: Theme.cardBg // Sliding selection pill — same tech as the power // profile selector; glides between the buttons. Rectangle { width: 40 height: 40 radius: Theme.radiusCard 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 width: 40 height: 40 SIcon { anchors.centerIn: parent text: sessBtn.modelData.icon // All four buttons share the logout // setup: base05, FILL on selection. color: Theme.base05 font.pixelSize: 20 font.weight: 600 } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onEntered: sessionMenu.selIdx = sessBtn.index onClicked: sessionMenu.activate(sessBtn.modelData.act) } } } } } } // Volume dropdown // Server monitor dropdown — the btop-at-a-distance view. BarDropdown { id: srvDropdown fullWidth: srvDropdownCol.width + 2 * Theme.panelGap fullHeight: srvDropdownCol.height + 2 * Theme.panelGap Column { id: srvDropdownCol anchors.centerIn: parent width: 340 spacing: 8 Card { width: parent.width Row { spacing: 6 SIcon { anchors.verticalCenter: parent.verticalCenter text: "dns" color: srvWidget.connected ? Theme.base0B : Theme.base08 } SText { anchors.verticalCenter: parent.verticalCenter text: "FredOS-Mediaserver" font.weight: Font.Medium } } SText { width: parent.width text: srvWidget.connected ? "up " + srvWidget.uptime + " • load " + srvWidget.load : "unreachable" font.pixelSize: 12 color: srvWidget.connected ? Theme.base04 : Theme.base08 elide: Text.ElideRight } Row { width: parent.width spacing: 10 SText { width: 42; text: "cpu"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter } PillSlider { width: parent.width - 42 - 76 - 20 anchors.verticalCenter: parent.verticalCenter value: srvWidget.cpu / 100 fillColor: srvWidget.cpu >= 90 ? Theme.base08 : Theme.base0D } SText { width: 76 text: srvWidget.cpu.toFixed(1) + "%" font.weight: Font.Medium horizontalAlignment: Text.AlignRight anchors.verticalCenter: parent.verticalCenter } } Row { width: parent.width spacing: 10 SText { width: 42; text: "ram"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter } PillSlider { width: parent.width - 42 - 76 - 20 anchors.verticalCenter: parent.verticalCenter value: srvWidget.memTotal > 0 ? srvWidget.memUsed / srvWidget.memTotal : 0 fillColor: Theme.base0C } SText { width: 76 text: srvWidget.memUsed.toFixed(1) + "/" + Math.round(srvWidget.memTotal) + "G" font.weight: Font.Medium horizontalAlignment: Text.AlignRight anchors.verticalCenter: parent.verticalCenter } } Row { width: parent.width spacing: 10 SText { width: 42; text: "temp"; color: Theme.base04 } SText { text: srvWidget.tempC < 0 ? "--" : srvWidget.tempC + "°C" font.weight: Font.Medium color: srvWidget.tempC < 0 ? Theme.base03 : srvWidget.tempC >= 85 ? Theme.base08 : srvWidget.tempC >= 70 ? Theme.base0A : Theme.base05 } } Row { width: parent.width spacing: 10 SText { width: 42; text: "wan"; color: Theme.base04 } Row { spacing: 14 Row { spacing: 4 SIcon { text: "arrow_downward"; font.pixelSize: 14; color: Theme.base0B; anchors.verticalCenter: parent.verticalCenter } SText { text: srvWidget.fmtRate(srvWidget.rxBps) font.weight: Font.Medium anchors.verticalCenter: parent.verticalCenter } } Row { spacing: 4 SIcon { text: "arrow_upward"; font.pixelSize: 14; color: Theme.base09; anchors.verticalCenter: parent.verticalCenter } SText { text: srvWidget.fmtRate(srvWidget.txBps) font.weight: Font.Medium anchors.verticalCenter: parent.verticalCenter } } } } Row { width: parent.width spacing: 10 SText { width: 42; text: "ping"; color: Theme.base04 } SText { text: srvWidget.pingMs < 0 ? "--" : srvWidget.pingMs.toFixed(1) + " ms" font.weight: Font.Medium color: srvWidget.pingMs < 0 ? Theme.base03 : Theme.base05 } } } Card { width: parent.width Row { spacing: 6 SIcon { anchors.verticalCenter: parent.verticalCenter; text: "monitor_heart" } SText { anchors.verticalCenter: parent.verticalCenter text: "Processes" font.weight: Font.Medium } } Row { width: parent.width SText { width: parent.width - 112; text: "name"; font.pixelSize: 11; color: Theme.base03 } SText { width: 56; text: "cpu%"; font.pixelSize: 11; color: Theme.base03; horizontalAlignment: Text.AlignRight } SText { width: 56; text: "mem%"; font.pixelSize: 11; color: Theme.base03; horizontalAlignment: Text.AlignRight } } Column { width: parent.width spacing: 3 Repeater { model: srvWidget.procs Row { required property var modelData width: parent.width height: 21 SText { width: parent.width - 112 text: modelData.name elide: Text.ElideRight anchors.verticalCenter: parent.verticalCenter } SText { width: 56 text: modelData.cpu.toFixed(1) horizontalAlignment: Text.AlignRight color: modelData.cpu >= 100 ? Theme.base08 : modelData.cpu >= 25 ? Theme.base0A : Theme.base05 anchors.verticalCenter: parent.verticalCenter } SText { width: 56 text: modelData.mem.toFixed(1) horizontalAlignment: Text.AlignRight color: Theme.base04 anchors.verticalCenter: parent.verticalCenter } } } SText { visible: srvWidget.procs.length === 0 text: "no data" color: Theme.base03 } } } } } BarDropdown { id: volDropdown fullWidth: volDropdownCol.width + 2 * Theme.panelGap fullHeight: volDropdownCol.height + 2 * Theme.panelGap Column { id: volDropdownCol anchors.centerIn: parent width: 268 spacing: 8 // Master volume card Card { width: parent.width Row { spacing: 6 VolIcon { anchors.verticalCenter: parent.verticalCenter; audioNode: volWidget.sink ? volWidget.sink.audio : null } 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 HoverRow { width: parent.width height: 28 onClicked: { if (volWidget.sink && volWidget.sink.audio) volWidget.sink.audio.muted = !volWidget.sink.audio.muted; } 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 } } } } ${lib.optionalString (!isMacbook) '' // Nova Pro noise cancelling — writes straight to the // base station's HID interface via the nova-anc // script (no daemon). Card hides when the dongle or // headset is gone. Card { id: ancCard visible: ancMode !== "" width: parent.width property string ancMode: "" function setMode(key) { if (key === ancMode) return; ancMode = key; // optimistic; next poll corrects if the device disagrees ancSet.mode = key; ancSet.running = true; } Process { id: ancSet property string mode: "off" command: [Commands.novaAnc, mode] } Process { id: ancStatus command: [Commands.novaAnc] stdout: StdioCollector { onStreamFinished: { const m = text.trim(); ancCard.ancMode = (m === "off" || m === "transparent" || m === "on") ? m : ""; } } } // Poll only while the dropdown is up; keeps ticking // with the card hidden so a headset appearing // mid-open reveals it. Timer { interval: 3000 repeat: true triggeredOnStart: true running: volDropdown.visible onTriggered: ancStatus.running = true } Row { spacing: 6 SIcon { anchors.verticalCenter: parent.verticalCenter; text: "headphones" } SText { anchors.verticalCenter: parent.verticalCenter text: "Noise Cancelling" font.weight: Font.Medium } } Row { width: parent.width spacing: 6 Repeater { model: [ { key: "off", label: "Off" }, { key: "transparent", label: "Transparent" }, { key: "on", label: "ANC" } ] Rectangle { required property var modelData property bool active: ancCard.ancMode === modelData.key width: (parent.width - 12) / 3 height: 26 radius: 6 color: active ? Theme.base0D : "transparent" border.color: active ? Theme.base0D : Theme.base03 border.width: 1 SText { anchors.centerIn: parent text: modelData.label font.pixelSize: 11 color: active ? Theme.base00 : Theme.base05 } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: ancCard.setMode(modelData.key) } } } } } ''} // 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 VolIcon { anchors.verticalCenter: parent.verticalCenter width: 18 color: Theme.base04 font.pixelSize: 15 audioNode: modelData.audio } PillSlider { width: parent.width - 18 - appVolLabel.width - 16 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 fullWidth: netDropdownCol.width + 2 * Theme.panelGap fullHeight: netDropdownCol.height + 2 * Theme.panelGap 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 } } HoverRow { visible: netWidget.netState === "connected" width: parent.width height: 28 onClicked: { netDisconnectProc.targetDevice = netWidget.netDevice; netDisconnectProc.running = true; netWidget.netState = "disconnected"; netWidget.netConn = ""; netWidget.netIcon = "wifi_off"; bar.closeAllDropdowns(); netRefreshDelay.start(); } SText { anchors.centerIn: parent text: "Disconnect" color: Theme.base08 font.pixelSize: 12 } } } // Available networks card Card { width: parent.width cardSpacing: 4 SText { text: "Available networks" color: Theme.base04 font.pixelSize: 11 } Repeater { model: netWidget.wifiNetworks HoverRow { required property var modelData width: parent.width height: 32 onClicked: { if (!modelData.active) { wifiConnectProc.targetSsid = modelData.ssid; wifiConnectProc.running = true; netRefreshDelay.start(); } bar.closeAllDropdowns(); } 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 } } } } } } } ${lib.optionalString isMacbook '' // Battery dropdown BarDropdown { id: batteryDropdown fullWidth: batteryDropdownCol.width + 2 * Theme.panelGap fullHeight: batteryDropdownCol.height + 2 * Theme.panelGap 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 : Theme.base02t 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 } } } } } } // Brightness card — screen + keyboard backlight. // Values are read on dropdown open (brightRead); writes // are debounced latest-wins so a drag doesn't spawn a // brightnessctl per pixel. Card { id: brightCard width: parent.width cardSpacing: 6 property real screenB: 0.5 property real kbdB: 0 SText { text: "Brightness"; color: Theme.base04; font.pixelSize: 11 } Row { width: parent.width spacing: 8 SIcon { width: 18; anchors.verticalCenter: parent.verticalCenter; text: "brightness_6"; font.pixelSize: 16 } PillSlider { width: parent.width - 26 anchors.verticalCenter: parent.verticalCenter value: brightCard.screenB onMoved: (v) => { brightCard.screenB = v; screenBTimer.restart(); } } } Row { width: parent.width spacing: 8 SIcon { width: 18; anchors.verticalCenter: parent.verticalCenter; text: "keyboard"; font.pixelSize: 16 } PillSlider { width: parent.width - 26 anchors.verticalCenter: parent.verticalCenter value: brightCard.kbdB onMoved: (v) => { brightCard.kbdB = v; kbdBTimer.restart(); } } } Timer { id: screenBTimer; interval: 80; onTriggered: screenBSet.running = true } // floor 1%: `set 0%` turns the panel fully off Process { id: screenBSet; command: ["brightnessctl", "set", Math.max(1, Math.round(brightCard.screenB * 100)) + "%"] } Timer { id: kbdBTimer; interval: 80; onTriggered: kbdBSet.running = true } Process { id: kbdBSet; command: ["brightnessctl", "-d", "smc::kbd_backlight", "set", Math.round(brightCard.kbdB * 100) + "%"] } Process { id: brightRead command: ["sh", "-c", "brightnessctl -m; brightnessctl -m -d smc::kbd_backlight"] stdout: StdioCollector { // machine-readable: device,class,current,percent%,max onStreamFinished: { for (let l of text.trim().split("\n")) { let f = l.split(","); if (f.length < 5) continue; let pct = parseInt(f[3]) / 100; if (f[0].indexOf("kbd") !== -1) brightCard.kbdB = pct; else brightCard.screenB = pct; } } } } } NightlightCard { width: parent.width } } } ''} ${lib.optionalString (!isMacbook) '' // Quick settings dropdown (desktop) — just the night light. BarDropdown { id: qsDropdown fullWidth: qsCol.width + 2 * Theme.panelGap fullHeight: qsCol.height + 2 * Theme.panelGap Column { id: qsCol anchors.centerIn: parent width: 240 spacing: 8 NightlightCard { width: parent.width } } } ''} // Calendar popup — GNOME-style two-pane panel. // Left: navigable month calendar + 7-day weather strip. // Right: MPRIS media controls + notification list. BarDropdown { id: calPopup // ceil: Text metrics give fractional sizes; fractional rect // edges render as soft 2px lines fullWidth: Math.ceil(calRow.width) + 2 * Theme.panelGap fullHeight: Math.ceil(calRow.height + 8 + notifCard.height) + 2 * Theme.panelGap // 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; // Feed today's real sunrise/sunset to the // night-light schedule (shell.qml). if (j.daily.sunrise && j.daily.sunset) { let sr = new Date(j.daily.sunrise[0]); let ss = new Date(j.daily.sunset[0]); bar.shellRoot.sunriseMins = sr.getHours() * 60 + sr.getMinutes(); bar.shellRoot.sunsetMins = ss.getHours() * 60 + ss.getMinutes(); bar.shellRoot.applyNightlight(); } } 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: one card per MPRIS player so Spotify, a // browser tab, etc. stay separate instead of collapsing // into a single combined player. --- property var players: Mpris.players.values.filter( p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing) // Best-effort link from an MPRIS player to its Pipewire // audio stream (matched by app name), so a card can carry a // volume slider via the same per-app path as the volume // widget. null when no stream matches. function streamFor(player) { if (!player) return null; let id = (player.identity || "").toLowerCase(); let ns = Pipewire.nodes.values; for (let i = 0; i < ns.length; i++) { let n = ns[i]; if (!n.isStream || !n.audio) continue; let an = (n.properties["application.name"] || "").toLowerCase(); if (an && (an === id || an.includes(id) || id.includes(an))) return n; } return 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 HoverRow { width: 28; height: 28; radius: Theme.radiusSmall anchors.left: parent.left onClicked: calPopup.shiftMonth(-1) SIcon { anchors.centerIn: parent text: "chevron_left" color: Theme.base05 font.pixelSize: 18 } } 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() } } HoverRow { width: 28; height: 28; radius: Theme.radiusSmall anchors.right: parent.right onClicked: calPopup.shiftMonth(1) SIcon { anchors.centerIn: parent text: "chevron_right" color: Theme.base05 font.pixelSize: 18 } } } 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.radiusCard color: Theme.cardBg 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 cards — one per active MPRIS source, // so Spotify and a browser tab stay separate. Repeater { model: calPopup.players Rectangle { id: mediaCard required property var modelData property var pwNode: calPopup.streamFor(modelData) width: parent.width height: mediaCol.height + 16 radius: Theme.radiusCard color: Theme.cardBg PwObjectTracker { objects: mediaCard.pwNode ? [mediaCard.pwNode] : [] } Column { id: mediaCol anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: 8 spacing: 8 // Album art with title + artist to its // right; controls and volume below. Row { width: parent.width spacing: 12 ClippingRectangle { width: 128; height: 128 radius: Theme.radiusSmall anchors.verticalCenter: parent.verticalCenter color: Theme.base02 SIcon { anchors.centerIn: parent visible: albumArt.status !== Image.Ready text: "music_note" color: Theme.base04 font.pixelSize: 48 } Image { id: albumArt anchors.fill: parent fillMode: Image.PreserveAspectCrop source: mediaCard.modelData.trackArtUrl } } Column { width: parent.width - 128 - 12 anchors.verticalCenter: parent.verticalCenter spacing: 2 SText { width: parent.width text: mediaCard.modelData.trackTitle color: Theme.base05 font.pixelSize: 13 font.weight: Font.Medium elide: Text.ElideRight } SText { width: parent.width text: mediaCard.modelData.trackArtist color: Theme.base04 font.pixelSize: 11 elide: Text.ElideRight } } } // Transport controls, centered below. Item { width: parent.width height: 36 Row { anchors.centerIn: parent spacing: 4 Repeater { model: [ { glyph: "skip_previous", act: "prev" }, { glyph: mediaCard.modelData.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" }, { glyph: "skip_next", act: "next" } ] HoverRow { id: mediaBtn required property var modelData width: 36; height: 36; radius: 18 onClicked: { let p = mediaCard.modelData; if (!p) return; if (mediaBtn.modelData.act === "prev") p.previous(); else if (mediaBtn.modelData.act === "next") p.next(); else p.togglePlaying(); } SIcon { anchors.centerIn: parent text: mediaBtn.modelData.glyph color: Theme.base05 font.pixelSize: 24 } } } } } // Per-source volume — same per-app path // as the volume widget. Shown when the // player's Pipewire stream is matched. Row { width: parent.width spacing: 8 visible: mediaCard.pwNode !== null && mediaCard.pwNode.audio !== null VolIcon { anchors.verticalCenter: parent.verticalCenter width: 18 color: Theme.base04 font.pixelSize: 15 audioNode: mediaCard.pwNode ? mediaCard.pwNode.audio : null } PillSlider { width: parent.width - 18 - mediaVolLabel.width - 16 anchors.verticalCenter: parent.verticalCenter height: 16 trackH: 4 value: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.min(1, mediaCard.pwNode.audio.volume) : 0 fillColor: mediaCard.pwNode && mediaCard.pwNode.audio && mediaCard.pwNode.audio.muted ? Theme.base03 : Theme.base0C onMoved: (v) => { if (mediaCard.pwNode && mediaCard.pwNode.audio) mediaCard.pwNode.audio.volume = v; } } SText { id: mediaVolLabel width: 36 text: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.round(mediaCard.pwNode.audio.volume * 100) + "%" : "0%" color: Theme.base04 font.pixelSize: 10 horizontalAlignment: Text.AlignRight anchors.verticalCenter: parent.verticalCenter } } } } } } } // ── Notifications: spans both columns, below the panes. // Header + one Card per notification, matching the media // cards instead of base02 blocks nested inside a big card. Column { id: notifCard anchors.top: calRow.bottom anchors.topMargin: 8 anchors.horizontalCenter: parent.horizontalCenter width: calRow.width opacity: calRow.opacity spacing: 8 Item { width: parent.width height: 20 SText { anchors.left: parent.left anchors.leftMargin: Theme.cardPad anchors.verticalCenter: parent.verticalCenter text: "Notifications" font.weight: Font.Medium } SText { anchors.right: parent.right anchors.rightMargin: Theme.cardPad 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 Card { id: notifItem required property var modelData readonly property string previewSource: bar.notifPreviewSource(notifItem.modelData) width: parent.width Item { width: parent.width height: Math.max(notifPreview.visible ? 48 : 0, ncBody.height) // Image preview (album art, screenshot thumb…) ClippingRectangle { id: notifPreview visible: notifItem.previewSource !== "" anchors.left: parent.left anchors.top: parent.top width: 48; height: 48 radius: Theme.radiusTiny color: Theme.base01 Image { anchors.fill: parent fillMode: Image.PreserveAspectCrop source: notifItem.previewSource } } NotifContent { id: ncBody notif: notifItem.modelData anchors.left: notifPreview.visible ? notifPreview.right : parent.left anchors.leftMargin: notifPreview.visible ? 8 : 0 anchors.right: dismissBtn.left anchors.rightMargin: 8 anchors.top: parent.top } SIcon { id: dismissBtn anchors.right: parent.right anchors.top: parent.top 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() } } } } } } } // ── OSD: volume / brightness level, shown inline in the bar's // empty middle — the gap between the two clusters exists for // this. Volume is reactive: Pipewire reports changes from any // source (media keys, pavucontrol, a hardware mixer), so no IPC // is needed. Brightness arrives via shellRoot's signal instead, // because sysfs backlight notifies with POLLPRI, which inotify // never sees. Focused monitor only. Item { id: osdItem readonly property bool isFocused: Hyprland.focusedMonitor && Hyprland.focusedMonitor.name === bar.screen.name // Every binding evaluates once at load; arm on a delay so // a shell restart doesn't flash the slider on screen. property bool armed: false property bool shown: false property string icon: "volume_up" property real value: 0 property color fill: Theme.base0D // Takes over the server glance's slot: fixed position, no // dependence on how tall the workspace column has grown. width: Theme.barWidth height: 170 anchors.horizontalCenter: barBgRect.horizontalCenter anchors.verticalCenter: srvWidget.verticalCenter opacity: shown ? 1 : 0 visible: opacity > 0.01 Behavior on opacity { NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic } } function show(ic, v, f) { if (!armed || !isFocused) return; icon = ic; value = v; fill = f; shown = true; osdTimer.restart(); } Timer { id: osdTimer; interval: 1600; onTriggered: osdItem.shown = false } Timer { id: osdArm; interval: 1500; running: true; onTriggered: osdItem.armed = true } function showVolume() { // The volume dropdown already shows the level live. if (bar.activeDropdown === volDropdown) return; show(volWidget.volIcon, volWidget.vol / 100, volWidget.muted ? Theme.base03 : Theme.base0D); } Connections { target: volWidget.sink && volWidget.sink.audio ? volWidget.sink.audio : null // `volume` notifies through volumesChanged, not // volumeChanged — it is a view over the per-channel // volumes vector. function onVolumesChanged() { osdItem.showVolume(); } function onMutedChanged() { osdItem.showVolume(); } } Connections { target: bar.shellRoot function onOsdBrightness(icon, value) { osdItem.show(icon, value, Theme.base0A); } } // Card body so the OSD reads as a surface, not floating // marks on the bar. Scales in from slightly small. Rectangle { anchors.centerIn: parent width: 36 height: osdCol.height + 2 * Theme.cardPad radius: Theme.radiusSmall color: Theme.cardBg scale: osdItem.shown ? 1 : 0.85 Behavior on scale { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } Column { id: osdCol anchors.centerIn: parent spacing: 6 SText { anchors.horizontalCenter: parent.horizontalCenter text: Math.round(Math.max(0, Math.min(1, osdItem.value)) * 100) color: Theme.base04 font.pixelSize: 11 } // Vertical track — fill grows upward from the bottom. // PillSlider is horizontal-only, so this is its own // rather than a rotated instance. Rectangle { anchors.horizontalCenter: parent.horizontalCenter width: 12 height: 96 radius: 6 color: Theme.base02 Rectangle { anchors.bottom: parent.bottom width: parent.width height: Math.max(parent.width, parent.height * Math.max(0, Math.min(1, osdItem.value))) radius: parent.radius color: osdItem.fill Behavior on height { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } SIcon { anchors.horizontalCenter: parent.horizontalCenter text: osdItem.icon color: Theme.base05 font.pixelSize: 16 } } } } // ── 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: Theme.barWidth y: Theme.frameWidth + 8 width: _toastRect.width + 4 height: _toastRect.height + 16 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.left: parent.left anchors.top: parent.top anchors.topMargin: 8 // Reveals rightward now, so width is the animated axis // and the card inside keeps a fixed width (otherwise it // would squash instead of being wiped in). width: toastItem.toastOpen ? 320 : 0 height: toastCard.height + 2 * Theme.panelGap clip: true Behavior on width { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } // Notification sits in a cardBg card like every other // dropdown surface, inset panelGap from the melt panel. Rectangle { id: toastCard anchors.top: parent.top anchors.left: parent.left anchors.margins: Theme.panelGap width: 320 - 2 * Theme.panelGap height: Math.max(toastPreview.visible ? 48 : 0, toastCol.height) + 16 radius: Theme.radiusCard color: Theme.cardBg property string previewSource: bar.notifPreviewSource(toastItem.currentNotif) // Image preview (screenshot thumb, album art…) ClippingRectangle { id: toastPreview visible: toastCard.previewSource !== "" anchors.left: parent.left anchors.top: parent.top anchors.margins: 8 width: 48; height: 48 radius: Theme.radiusTiny color: Theme.base01 Image { anchors.fill: parent fillMode: Image.PreserveAspectCrop source: toastCard.previewSource } } NotifContent { id: toastCol notif: toastItem.currentNotif anchors.left: toastPreview.visible ? toastPreview.right : 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(); } } } } } } } ''; }; }; }; }; }