nixos/settings/quickshell.nix
rope 01b771b17d quickshell: fix ping path, no wrapper on NixOS
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:45:22 +01:00

3946 lines
199 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
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"
// 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 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
}
// 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: slim rounded track + fill with a full-height
// invisible hit area. `value` is 0..1; `moved(v)` fires on drag.
component PillSlider: Item {
property real value: 0
property color fillColor: Theme.base0D
property real trackH: 6
signal moved(real v)
height: 20
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: parent.trackH
radius: parent.trackH / 2
color: Theme.base02
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.value > 0 ? Math.max(parent.trackH, parent.value * parent.width) : 0
height: parent.trackH
radius: parent.trackH / 2
color: parent.fillColor
Behavior on width { NumberAnimation { duration: 80 } }
}
MouseArea {
anchors.fill: parent
function set(mouse) { parent.moved(Math.max(0, Math.min(1, mouse.x / width))); }
onPressed: (mouse) => set(mouse)
onPositionChanged: (mouse) => { if (pressed) set(mouse); }
}
}
// 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. Results sit above the
// search box; selection uses an animated ListView highlight.
Item {
id: launcherPanel
property bool open: false
readonly property real panelW: 420
property real targetH: 36 + launcherList.contentHeight
+ (launcherList.count > 0 ? 8 + 2 * Theme.cardPad : 0) + 24
// Both axes animate: expands from a small point on the
// bottom edge (like the top dropdowns' stub seed).
property real openH: open ? targetH : 0
property real openW: open ? panelW : 80
Behavior on openH {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
Behavior on openW {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
x: Math.round((bar.width - openW) / 2)
y: bar.height - Theme.frameWidth - openH
width: openW
height: openH
visible: openH > 0.5
function toggle() {
open = !open;
if (open) {
searchInput.text = "";
launcherList.currentIndex = 0;
searchInput.forceActiveFocus();
}
}
function activate(item) {
if (!item) return;
item.execute();
open = false;
}
function score(name, extra, q) {
let n = name.toLowerCase();
if (n.startsWith(q)) return 5;
if (n.includes(" " + q)) return 4;
if (n.includes(q)) return 3;
if (extra && extra.toLowerCase().includes(q)) return 2;
// Fuzzy: q as an in-order subsequence of n (vktop
// vesktop); fewer skipped characters scores higher.
let qi = 0, gaps = 0, last = -1;
for (let i = 0; i < n.length && qi < q.length; i++) {
if (n[i] === q[qi]) {
if (last >= 0) gaps += i - last - 1;
last = i;
qi++;
}
}
if (qi === q.length) return 1 / (1 + gaps);
return 0;
}
property var entries: {
let q = searchInput.text.toLowerCase().trim();
let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay);
if (q === "") {
apps.sort((a, b) => a.name.localeCompare(b.name));
return apps.slice(0, 8);
}
let scored = [];
for (let i = 0; i < apps.length; i++) {
let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q);
if (s > 0) scored.push({ app: apps[i], s: s });
}
scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name));
return scored.slice(0, 8).map(x => x.app);
}
// Content anchored to the bottom so the grow reveals upward
Item {
anchors.fill: parent
clip: true
opacity: launcherPanel.open ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
}
Column {
anchors.bottom: parent.bottom
anchors.bottomMargin: 12
anchors.horizontalCenter: parent.horizontalCenter
width: launcherPanel.panelW - 24
spacing: 8
// Results sit in a base01 card segment, like the
// notification list and the other dropdowns.
Rectangle {
width: parent.width
height: launcherList.contentHeight + 2 * Theme.cardPad
radius: Theme.radiusCard
color: Theme.cardBg
visible: launcherList.count > 0
ListView {
id: launcherList
anchors.fill: parent
anchors.margins: Theme.cardPad
interactive: false
model: launcherPanel.entries
highlight: Rectangle {
radius: Theme.radiusSmall
color: Theme.base02
}
highlightMoveDuration: 200
highlightMoveVelocity: -1
highlightResizeDuration: 0
delegate: Item {
required property var modelData
required property int index
width: launcherList.width
height: 32
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 10
spacing: 10
Image {
visible: source != ""
anchors.verticalCenter: parent.verticalCenter
width: 18
height: 18
sourceSize.width: 18
sourceSize.height: 18
source: Quickshell.iconPath(modelData.icon, true)
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: Theme.base05
font.pixelSize: 13
elide: Text.ElideRight
width: 330
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: launcherList.currentIndex = index
onClicked: launcherPanel.activate(modelData)
}
}
}
}
Rectangle {
width: parent.width
height: 36
radius: Theme.radiusCard
color: Theme.cardBg
SIcon {
id: searchIcon
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: "search"
color: Theme.base04
font.pixelSize: 16
}
TextInput {
id: searchInput
anchors.left: searchIcon.right
anchors.leftMargin: 8
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
clip: true
onTextChanged: launcherList.currentIndex = 0
Keys.onEscapePressed: launcherPanel.open = false
Keys.onUpPressed: launcherList.currentIndex = Math.max(0, launcherList.currentIndex - 1)
Keys.onDownPressed: launcherList.currentIndex = Math.min(launcherPanel.entries.length - 1, launcherList.currentIndex + 1)
Keys.onTabPressed: launcherList.currentIndex = (launcherList.currentIndex + 1) % Math.max(1, launcherPanel.entries.length)
Keys.onReturnPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex])
Keys.onEnterPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex])
}
SText {
anchors.left: searchIcon.right
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
visible: searchInput.text === ""
text: "Search"
color: Theme.base03
font.pixelSize: 13
}
}
}
}
}
// Click-outside dismissal for 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 bar is a column
// now, so the two clusters anchor to opposite ends and the gap
// between them is the OSD slot.
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()
}
}
// 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();
}
}
}
}
// 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()
}
}
}
}
}
// Bottom cluster: status icons, tray, power button. Anchored
// to the bottom edge so the gap above it between here and the
// workspace dots stays free as the OSD slot.
Column {
id: barBottom
anchors.bottom: parent.bottom
anchors.bottomMargin: 12
anchors.horizontalCenter: barBgRect.horizontalCenter
spacing: 16
// 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 mini meters + ping ms; click
// opens the btop-style dropdown.
Item {
id: srvWidget
width: Theme.barWidth
height: 42
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 var _procsPending: []
property bool _collecting: false
function _parseLine(line) {
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",
"env", "LC_ALL=C", "top", "-b", "-d", "2", "-w", "512"]
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;
});
}
Column {
anchors.centerIn: parent
spacing: 4
Rectangle {
width: 24; height: 3; radius: 1.5
color: Theme.base02
Rectangle {
width: srvWidget.connected ? Math.max(2, parent.width * Math.min(1, srvWidget.cpu / 100)) : 0
height: parent.height; radius: parent.radius
color: Theme.base0D
Behavior on width { NumberAnimation { duration: 300 } }
}
}
Rectangle {
width: 24; height: 3; radius: 1.5
color: Theme.base02
Rectangle {
width: srvWidget.connected && srvWidget.memTotal > 0
? Math.max(2, parent.width * Math.min(1, srvWidget.memUsed / srvWidget.memTotal)) : 0
height: parent.height; radius: parent.radius
color: Theme.base0C
Behavior on width { NumberAnimation { duration: 300 } }
}
}
SText {
anchors.horizontalCenter: parent.horizontalCenter
text: srvWidget.pingMs < 0 ? "--"
: srvWidget.pingMs < 10 ? srvWidget.pingMs.toFixed(1)
: Math.round(srvWidget.pingMs).toString()
font.pixelSize: 9
color: 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();
}
}
}
}
// 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.
Item { width: Theme.barWidth; height: 10 }
// 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.
Item { width: Theme.barWidth; height: 10 }
// 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
// traytray 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: 300
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: 10
color: srvWidget.connected ? Theme.base04 : Theme.base08
elide: Text.ElideRight
}
Row {
width: parent.width
spacing: 8
SText { width: 32; text: "cpu"; font.pixelSize: 11; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter }
PillSlider {
width: parent.width - 32 - 64 - 16
height: 16; trackH: 4
anchors.verticalCenter: parent.verticalCenter
value: srvWidget.cpu / 100
fillColor: srvWidget.cpu >= 90 ? Theme.base08 : Theme.base0D
}
SText {
width: 64
text: srvWidget.cpu.toFixed(1) + "%"
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
width: parent.width
spacing: 8
SText { width: 32; text: "ram"; font.pixelSize: 11; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter }
PillSlider {
width: parent.width - 32 - 64 - 16
height: 16; trackH: 4
anchors.verticalCenter: parent.verticalCenter
value: srvWidget.memTotal > 0 ? srvWidget.memUsed / srvWidget.memTotal : 0
fillColor: Theme.base0C
}
SText {
width: 64
text: srvWidget.memUsed.toFixed(1) + "/" + Math.round(srvWidget.memTotal) + "G"
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
width: parent.width
spacing: 8
SText { width: 32; text: "ping"; font.pixelSize: 11; color: Theme.base04 }
SText {
text: srvWidget.pingMs < 0 ? "--" : srvWidget.pingMs.toFixed(1) + " ms"
font.pixelSize: 11
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 - 88; text: "name"; font.pixelSize: 10; color: Theme.base03 }
SText { width: 44; text: "cpu%"; font.pixelSize: 10; color: Theme.base03; horizontalAlignment: Text.AlignRight }
SText { width: 44; text: "mem%"; font.pixelSize: 10; color: Theme.base03; horizontalAlignment: Text.AlignRight }
}
Column {
width: parent.width
spacing: 2
Repeater {
model: srvWidget.procs
Row {
required property var modelData
width: parent.width
height: 17
SText {
width: parent.width - 88
text: modelData.name
font.pixelSize: 11
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
SText {
width: 44
text: modelData.cpu.toFixed(1)
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
color: modelData.cpu >= 100 ? Theme.base08
: modelData.cpu >= 25 ? Theme.base0A : Theme.base05
anchors.verticalCenter: parent.verticalCenter
}
SText {
width: 44
text: modelData.mem.toFixed(1)
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
color: Theme.base04
anchors.verticalCenter: parent.verticalCenter
}
}
}
SText {
visible: srvWidget.procs.length === 0
text: "no data"
font.pixelSize: 11
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
}
}
}
}
// 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
width: Theme.barWidth
anchors.horizontalCenter: barBgRect.horizontalCenter
anchors.top: barTop.bottom
anchors.bottom: barBottom.top
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);
}
}
Column {
anchors.centerIn: parent
spacing: 8
// 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: 6
height: 96
radius: 3
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: 80 } }
}
}
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(); }
}
}
}
}
}
}
'';
};
};
};
};
}