nixos/settings/quickshell.nix
rope a8eb76782c quickshell: stop launcher rows fading to random opacities
The results ListView had add/remove transitions animating opacity and
scale on the delegate root. The model is re-diffed on every keystroke, so
a row part-way through a fade frequently survived the next pass as a move
instead of a remove — and move animates x,y only, leaving the
half-finished opacity permanently unrestored. Rows accumulated stuck at
arbitrary opacities depending on typing speed, which read as the result
colours randomly fading.

Dropped both fades. x,y stay animated because the view re-asserts them on
every layout, so an interrupted slide self-corrects; opacity and scale
have no such backstop. Comment records that a future fade belongs on a
child item, not the root the view recycles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:42:37 +01:00

6115 lines
318 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. Lucide is ligature-based like Material Symbols
# was (text "wifi-high" renders the icon), so it drops straight in — only
# the names change. UI text font comes from stylix.
fonts.packages = [ pkgs.lucide ];
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 sans choice so a font swap propagates to the bar
sansFont = osConfig.stylix.fonts.sansSerif.name;
# Shell chrome fragment shader: the bar, screen frame, dropdown panel
# and toast are one signed-distance field merged with a circular
# smooth-min (caelestia-style liquid junctions); the 2px border is the
# distance band just inside the surface, so it follows every fillet.
# Qt 6 requires shaders precompiled to .qsb — done here at build time.
chromeFragSrc = pkgs.writeText "shell-chrome.frag" ''
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
vec4 cutout; // cx, cy, hw, hh rounded inner screen cutout
vec4 panel; // cx, cy, hw, hh dropdown panel (hw <= 0: none)
vec4 toast; // cx, cy, hw, hh toast (hw <= 0: none)
vec4 launcher; // cx, cy, hw, hh bottom launcher (hw <= 0: none)
vec4 fillColor; // straight (non-premultiplied) rgba
vec4 borderColor;
vec2 res;
float cutoutR;
float panelR;
float meltK;
float borderW;
};
float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float r) {
vec2 d = abs(p - center) - halfSize + vec2(r);
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r;
}
// Circular smooth min: the blend fillet is a true circular arc of
// radius k tangent to both surfaces.
float smin(float a, float b, float k) {
return max(k, min(a, b)) - length(max(vec2(k) - vec2(a, b), vec2(0.0)));
}
void main() {
vec2 p = qt_TexCoord0 * res;
// Shell = bar band + frame band = everything outside the cutout
float d = -sdRoundedBox(p, cutout.xy, cutout.zw, cutoutR);
if (panel.z > 0.5)
d = smin(d, sdRoundedBox(p, panel.xy, panel.zw, panelR), meltK);
if (toast.z > 0.5)
d = smin(d, sdRoundedBox(p, toast.xy, toast.zw, panelR), meltK);
if (launcher.z > 0.5)
d = smin(d, sdRoundedBox(p, launcher.xy, launcher.zw, panelR), meltK);
float fw = fwidth(d);
// 1 inside the union, 0 outside (antialiased)
float edge = 1.0 - smoothstep(-fw, fw, d);
// 1 deeper than the border band, 0 within it
float inner = 1.0 - smoothstep(-borderW - fw, -borderW + fw, d);
vec4 c = mix(borderColor, fillColor, inner);
fragColor = vec4(c.rgb * c.a, c.a) * edge * qt_Opacity;
}
'';
chromeShader = pkgs.runCommand "shell-chrome.frag.qsb" {
nativeBuildInputs = [ pkgs.qt6.qtshadertools ];
} ''
qsb --glsl "300 es,330" --hlsl 50 --msl 12 -o $out ${chromeFragSrc}
'';
# 7-day forecast JSON from Open-Meteo (no API key). Location is
# auto-detected by IP via ipinfo.io, falling back to London.
# Nova Pro Wireless ANC, no daemon: talk straight to the base station's
# vendor HID interface (iface 4). Report id 0x06 leads every 64-byte
# packet; opcode 0xbd sets ANC (0 off / 1 transparent / 2 on), 0xb0
# requests full status (anc @ 0x0a, power @ 0x0f, 0x08 = on-head).
# Protocol lifted from Arctis-Sound-Manager's nova_pro_wireless.yaml.
novaAncScript = pkgs.writeScript "nova-anc" ''
#!${pkgs.python3}/bin/python3
"""nova-anc [off|transparent|on] no arg prints current mode."""
import glob, os, select, sys, time
MODES = {"off": 0, "transparent": 1, "on": 2}
NAMES = {0: "off", 1: "transparent", 2: "on"}
PIDS = {0x12e0, 0x12e5, 0x225d}
def dongle():
for h in glob.glob("/sys/class/hidraw/hidraw*"):
try:
uevent = open(h + "/device/uevent").read()
iface = open(h + "/device/../bInterfaceNumber").read().strip()
except OSError:
continue
for line in uevent.splitlines():
if line.startswith("HID_ID="):
_, vid, pid = line.split("=")[1].split(":")
if int(vid, 16) == 0x1038 and int(pid, 16) in PIDS and iface == "04":
return "/dev/" + os.path.basename(h)
return None
dev = dongle()
if not dev:
sys.exit(1)
fd = os.open(dev, os.O_RDWR)
if len(sys.argv) > 1:
os.write(fd, bytes([0x06, 0xbd, MODES[sys.argv[1]]]).ljust(64, b"\0"))
print(sys.argv[1])
sys.exit(0)
os.write(fd, bytes([0x06, 0xb0]).ljust(64, b"\0"))
deadline = time.time() + 1.0
while (left := deadline - time.time()) > 0:
if not select.select([fd], [], [], left)[0]:
break
pkt = os.read(fd, 64)
# Skip unsolicited 0x07xx event packets until the status reply
if len(pkt) > 0x0f and pkt[0] == 0x06 and pkt[1] == 0xb0:
if pkt[0x0f] != 0x08:
break # headset off / in cradle -> report nothing
print(NAMES.get(pkt[0x0a], "off"))
sys.exit(0)
sys.exit(1)
'';
weatherFetchScript = pkgs.writeShellScript "weather-fetch" ''
loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true)
case "$loc" in
*,*) lat=''${loc%,*}; lon=''${loc#*,} ;;
*) lat=51.51; lon=-0.13 ;;
esac
${pkgs.curl}/bin/curl -sf --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset&timezone=auto&forecast_days=7"
'';
# Last-resort artwork: when a page publishes no MediaSession artwork
# (Jellyfin's web player, a bare <video> tag), fall back to its
# favicon. Filenames are often content-hashed, so /favicon.ico can't
# be guessed — the page's <link> tags have to be read. Prefers
# apple-touch-icon (180px) over favicon.ico (16px). Prints nothing on
# any failure, which the QML treats as "no icon".
faviconScript = pkgs.writeShellScript "favicon-fetch" ''
url="$1"
[ -z "$url" ] && exit 0
case "$url" in http://*|https://*) ;; *) exit 0 ;; esac
page=''${url%%#*} # drop SPA fragment (#/video)
proto=''${page%%://*}
rest=''${page#*://}
host=''${rest%%/*}
origin="$proto://$host"
dir=''${page%/*}/
html=$(${pkgs.curl}/bin/curl -sfL --max-time 5 "$page" 2>/dev/null)
[ -z "$html" ] && html=$(${pkgs.curl}/bin/curl -sfL --max-time 5 "$origin/" 2>/dev/null)
[ -z "$html" ] && exit 0
# Minified HTML is one long line, so pull each <link> out with
# grep -o rather than trying to split on newlines.
pick() {
printf '%s' "$html" \
| grep -oiE '<link[^>]*>' \
| grep -iE "rel=[\"']?$1" \
| grep -oiE "href=[\"'][^\"']+[\"']" \
| head -1 | sed -E "s/^href=[\"']//; s/[\"']\$//"
}
href=$(pick "apple-touch-icon")
[ -z "$href" ] && href=$(pick "(shortcut +)?icon[\"' ]")
[ -z "$href" ] && exit 0
case "$href" in
http://*|https://*) printf '%s' "$href" ;;
//*) printf '%s:%s' "$proto" "$href" ;;
/*) printf '%s%s' "$origin" "$href" ;;
*) printf '%s%s' "$dir" "$href" ;;
esac
'';
# Launcher instant answers. Wikipedia's full-text search and the intro
# extract in ONE request: generator=search picks the article, so a
# natural-language query ("mount everest height") works where the
# title-only endpoints return nothing. --data-urlencode keeps the query
# out of the URL literal.
#
# Three candidates, not one: search rank puts "Chernobyl (miniseries)"
# above the city, so the QML picks between them (see pickPage).
#
# Prints nothing on any failure, which the QML treats as "no answer" —
# including the empty 4xx/5xx that Wikipedia returns when a burst of
# requests gets throttled.
answerFetchScript = pkgs.writeShellScript "answer-fetch" ''
q="$1"
[ -z "$q" ] && exit 0
${pkgs.curl}/bin/curl -sf --max-time 6 -G \
--data-urlencode "gsrsearch=$q" \
-H 'User-Agent: quickshell-launcher/1.0 (personal desktop use)' \
'https://en.wikipedia.org/w/api.php?action=query&format=json&generator=search&gsrlimit=3&prop=extracts&exintro&explaintext&redirects=1'
'';
# Launcher LLM answers, via the mediaserver. The API key lives on the
# server ONLY — the desktops just pipe a question over the same SSH
# path the server monitor already uses, so no laptop ever holds a
# credential and there's one place to rotate it. The remote end is
# `qs-ask` in hosts/FredOS-Mediaserver.nix.
#
# The query goes over STDIN, not as an argv element: `ssh host cmd arg`
# re-parses arg through the remote shell, so a question containing
# quotes would break. stdin sidesteps remote quoting entirely.
#
# Prints nothing when the key is missing, the server is unreachable, the
# call fails, or the model says it doesn't know — each of those falls
# through to the Wikipedia answer.
askFetchScript = pkgs.writeShellScript "ask-fetch" ''
q="$1"
[ -z "$q" ] && exit 0
printf '%s' "$q" | ${pkgs.openssh}/bin/ssh \
-o BatchMode=yes -o ConnectTimeout=4 \
-o StrictHostKeyChecking=accept-new \
fred@10.0.0.1 qs-ask 2>/dev/null
'';
# cava in raw mode: one newline-terminated frame per tick, bars as
# semicolon-separated 0-100 ints — trivially parsed by SplitParser.
# Drives the media card's spectrum ring. Only spawned while the
# calendar popup is open, so nothing captures audio at idle.
cavaConfig = pkgs.writeText "cava-quickshell.conf" ''
[general]
framerate = 30
bars = 24
autosens = 1
[input]
method = pipewire
source = auto
[output]
method = raw
data_format = ascii
ascii_max_range = 100
channels = mono
'';
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
Cheatsheet 1.0 Cheatsheet.qml
'';
};
"quickshell/Theme.qml" = {
onChange = qsRestart;
text = ''
pragma Singleton
import QtQuick
QtObject {
// Surfaces: one hue, three steps. base01/base02 are tints of
// base00 rather than stylix's own base01/base02, which come
// straight off the wallpaper and routinely land off-hue a
// purple card floating over a slate bar. Tinting keeps
// bar card hover reading as one stack. Two numbers, and
// they are the whole surface ladder.
readonly property color base00: "#${c.base00}"
readonly property color base01: Qt.lighter(base00, 1.7) // card
readonly property color base02: Qt.lighter(base00, 2.4) // hover / chip / track
// Ink, wallpaper-derived: base05 primary, base04 muted,
// base03 disabled (also the border colour).
readonly property color base03: "#${c.base03}"
readonly property color base04: "#${c.base04}"
readonly property color base05: "#${c.base05}"
// The one themed accent: active, focused, selected, filled.
readonly property color base0D: "#${c.base0D}"
// Status colours: fixed hues, pulled toward the theme.
// Sourcing these from stylix doesn't work base08-base0F all
// collapse to near-identical values on a monochrome wallpaper,
// which leaves "battery critical" and "battery low" the same
// colour. So the HUE is fixed and only the hue is: each one is
// then mixed toward base0D, which keeps red reading as red
// while landing it in the wallpaper's family instead of looking
// pasted on. Blending toward base0D and not toward the card
// keeps luminance roughly intact, so these stay legible as text
// on cardBg mixing into the background would sink them.
readonly property real statusHarmony: 0.25 // 0 = pure hue, 1 = pure accent
function harmonised(hex) {
let h = Qt.color(hex);
return Qt.tint(base0D, Qt.rgba(h.r, h.g, h.b, 1 - statusHarmony));
}
readonly property color err: harmonised("#e0525f") // critical: temp 85, batt 15, auth failed
readonly property color warn: harmonised("#e0a44b") // caution: temp 70, batt 30
readonly property color ok: harmonised("#68c17c") // connected / active / net down
readonly property color info: harmonised("#5aa6e0") // memory
readonly property color alt: harmonised("#b98ce0") // net up
readonly property color barBg: Qt.rgba(base00.r, base00.g, base00.b, 0.70)
// Card surfaces are translucent so the compositor's bar-layer
// blur shows through a lessened blur over the panel tint.
readonly property color cardBg: Qt.rgba(base01.r, base01.g, base01.b, 0.80)
// 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: Qt.rgba(base02.r, base02.g, base02.b, 0)
readonly property string fontFamily: "${sansFont}"
// Ligature-based icon font: text "wifi-high" renders the icon
readonly property string iconFont: "lucide"
// 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.err
: 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.err
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-right"
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/Cheatsheet.qml" = {
onChange = qsRestart;
text = ''
import Quickshell
import Quickshell.Wayland
import QtQuick
// Keybind cheat sheet Super+C routes here via shell.qml's
// "cheatsheet" IpcHandler. The list is hand-maintained: the real
// binds live in settings/hyprland.nix's Lua config and the
// spotify-player ones are that app's built-in defaults, so both
// need updating here too when they change.
Scope {
id: root
property bool open: false
readonly property var sections: [
{ title: "Apps", binds: [
["Super + T", "Terminal"],
["Super + E", "Files"],
["Super + Z", "Zen browser"],
["Super + R", "Launcher"],
["Super + I", "Audio settings"],
["Super + A", "Colour picker"],
["Super + C", "This cheat sheet"],
]},
{ title: "Windows", binds: [
["Super + Q", "Close window"],
["Super + V", "Toggle floating"],
["Super + F", "Fullscreen"],
["Super + P", "Pseudo tile"],
["Super + S", "Toggle split"],
["Super + drag", "Move window"],
["Super + right-drag", "Resize window"],
]},
{ title: "Focus & workspaces", binds: [
["Super + ", "Focus window"],
["Super + H/J/K", "Focus left/down/up"],
["Super + Shift + ", "Move window"],
["Super + 0-9", "Go to workspace"],
["Super + Shift + 0-9", "Send to workspace"],
["Super + scroll", "Cycle workspaces"],
]},
{ title: "Session", binds: [
["Super + L", "Power menu"],
["Super + Shift + E", "Exit Hyprland"],
["Super + Shift + S", "Screenshot region"],
["Print", "Screenshot screen"],
]},
{ title: "Spotify Player", binds: [
["?", "Built-in help"],
["space", "Play / pause"],
["n / p", "Next / previous"],
["+ / -", "Volume"],
["< / >", "Seek"],
["D", "Switch device"],
["g s", "Search all of Spotify"],
["/", "Filter current page"],
["z", "Queue"],
["l", "Lyrics"],
["g l", "Library"],
["R", "Restart audio client"],
["tab", "Next pane"],
["q", "Quit"],
]},
]
PanelWindow {
visible: root.open
WlrLayershell.namespace: "quickshell-cheatsheet"
WlrLayershell.layer: WlrLayer.Overlay
// Exclusive works here (unlike the bar): this window uses no
// HyprlandFocusGrab, so nothing fights it for focus.
WlrLayershell.keyboardFocus: root.open
? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
anchors { top: true; left: true; right: true; bottom: true }
color: "transparent"
// Click anywhere outside the card to dismiss.
MouseArea {
anchors.fill: parent
onClicked: root.open = false
Rectangle { anchors.fill: parent; color: "#99000000" }
}
Item {
anchors.fill: parent
focus: true
Keys.onPressed: e => {
if (e.key === Qt.Key_Escape || e.key === Qt.Key_C
|| e.key === Qt.Key_Q)
root.open = false;
}
Rectangle {
anchors.centerIn: parent
width: grid.width + Theme.panelGap * 6
height: grid.height + Theme.panelGap * 6
radius: Theme.radius
color: Theme.cardBg
border.width: Theme.borderWidth
border.color: Theme.base02
// Swallow clicks on the card so it doesn't close.
MouseArea { anchors.fill: parent }
Row {
id: grid
anchors.centerIn: parent
spacing: Theme.panelGap * 4
Repeater {
model: root.sections
Column {
required property var modelData
spacing: 4
Text {
text: modelData.title
font.family: Theme.fontFamily
font.pixelSize: 15
font.bold: true
color: Theme.base0D
bottomPadding: 4
}
Repeater {
model: modelData.binds
Row {
required property var modelData
spacing: 10
Text {
width: 150
text: modelData[0]
font.family: Theme.fontFamily
font.pixelSize: 13
color: Theme.base0D
}
Text {
text: modelData[1]
font.family: Theme.fontFamily
font.pixelSize: 13
color: Theme.base05
}
}
}
}
}
}
}
}
}
}
'';
};
"quickshell/Commands.qml" = {
onChange = qsRestart;
text = ''
pragma Singleton
import QtQuick
QtObject {
readonly property string nmcli: "${nmcli}"
readonly property string wifiConnect: "${wifiConnectScript}"
readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play"
readonly property string systemctl: "${pkgs.systemd}/bin/systemctl"
readonly property string weatherFetch: "${weatherFetchScript}"
readonly property string ssh: "${pkgs.openssh}/bin/ssh"
readonly property string novaAnc: "${novaAncScript}"
// Unprivileged ping works via ICMP dgram sockets NixOS sets
// net.ipv4.ping_group_range for everyone (no wrapper exists).
readonly property string ping: "${pkgs.iputils}/bin/ping"
// Recoloured to the theme accent at draw time (ColorOverlay),
// so the stock blue/grey snowflake artwork is fine here.
readonly property string nixLogo: "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg"
readonly property string faviconFetch: "${faviconScript}"
readonly property string cava: "${pkgs.cava}/bin/cava"
readonly property string cavaConfig: "${cavaConfig}"
readonly property string wlCopy: "${pkgs.wl-clipboard}/bin/wl-copy"
readonly property string fd: "${pkgs.fd}/bin/fd"
readonly property string answerFetch: "${answerFetchScript}"
readonly property string askFetch: "${askFetchScript}"
readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open"
}
'';
};
"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(); }
}
// Keybind cheat sheet Super+C in hyprland.nix.
Cheatsheet { id: cheatsheet }
IpcHandler {
target: "cheatsheet"
function toggle(): void { cheatsheet.open = !cheatsheet.open; }
}
// 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"
stdout: StdioCollector {
// machine-readable: device,class,current,percent%,max
onStreamFinished: {
let f = text.trim().split("\n")[0].split(",");
if (f.length < 5) return;
let v = parseInt(f[3]) / 100;
// "brightness" is a request to grade the glyph by
// level; the kbd path passes a literal icon. Lucide
// gives three sun steps, not seven.
let ic = brightProc.osdIcon === "brightness"
? (v >= 0.66 ? "sun" : v >= 0.33 ? "sun-medium" : "sun-dim")
: brightProc.osdIcon;
root.osdBrightness(ic, v);
}
}
}
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"); }
function brightnessDown(): void { root.setBrightness(["-n", "1", "set", "5%-"], "brightness"); }
function kbdUp(): void { root.setBrightness(["-d", "smc::kbd_backlight", "set", "10%+"], "keyboard"); }
function kbdDown(): void { root.setBrightness(["-d", "smc::kbd_backlight", "set", "10%-"], "keyboard"); }
}
// Night light: quickshell owns the schedule, the hyprsunset
// daemon just applies the gamma (hyprctl hyprsunset ).
// sunrise/sunset default to 7:00/21:00 and are replaced with
// real times by the bar's open-meteo weather fetch.
property int sunriseMins: 7 * 60
property int sunsetMins: 21 * 60
readonly property var nightlight: nlState
FileView {
id: nlFile
path: Quickshell.env("HOME") + "/.local/state/quickshell-nightlight.json"
printErrors: false
adapter: JsonAdapter {
id: nlState
property bool autoOn: true
property real warmth: 0.5 // 0 = 6500K 1 = 2500K
}
onLoaded: root.applyNightlight()
onLoadFailed: writeAdapter() // first run: create with defaults
}
function setNightlight(autoOn, warmth) {
nlState.autoOn = autoOn;
nlState.warmth = warmth;
applyNightlight();
nlFile.writeAdapter();
}
function applyNightlight() {
let w = nlState.warmth;
if (nlState.autoOn) {
let now = new Date();
let mins = now.getHours() * 60 + now.getMinutes();
if (mins >= sunriseMins && mins < sunsetMins) w = 0; // daytime
}
nlApply.command = w < 0.02
? ["hyprctl", "hyprsunset", "identity"]
: ["hyprctl", "hyprsunset", "temperature", String(Math.round(6500 - w * 4000))];
nlApply.running = true;
}
Process { id: nlApply }
Timer {
// Re-assert once a minute: crosses the sunrise/sunset
// boundaries and heals a hyprsunset daemon that (re)started
// after us.
interval: 60000; running: true; repeat: true; triggeredOnStart: true
onTriggered: root.applyNightlight()
}
NotificationServer {
id: _notifServer
bodySupported: true
actionsSupported: true
imageSupported: true
persistenceSupported: true
keepOnReload: true
onNotification: (notification) => {
notification.tracked = true;
root.latestNotification = notification;
root.notificationReceived();
}
}
// Wallpaper replaces the hyprpaper daemon.
Variants {
model: Quickshell.screens
PanelWindow {
required property var modelData
screen: modelData
WlrLayershell.layer: WlrLayer.Background
exclusionMode: ExclusionMode.Ignore
anchors { top: true; bottom: true; left: true; right: true }
color: "black"
Image {
anchors.fill: parent
source: "file://" + Quickshell.env("HOME") + "/.local/share/backgrounds/wallpaper.png"
cache: false // same-URL pixmap cache survives soft reload
fillMode: Image.PreserveAspectCrop
}
}
}
Variants {
model: Quickshell.screens
Bar {
notifServer: _notifServer
shellRoot: root
}
}
}
'';
};
"quickshell/Bar.qml" = {
onChange = qsRestart;
text = ''
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import Quickshell.Services.SystemTray
import Quickshell.Services.Notifications
import Quickshell.Services.Pipewire
import Quickshell.Services.UPower
import Quickshell.Services.Mpris
import Quickshell.Widgets
import Quickshell.Io
import QtQuick
import QtQml.Models
import Qt5Compat.GraphicalEffects
PanelWindow {
id: bar
required property var modelData
required property NotificationServer notifServer
required property var shellRoot
screen: modelData
WlrLayershell.namespace: "quickshell-bar"
// OnDemand + HyprlandFocusGrab is the working combination
// (caelestia's): the grab redirects focus to this window and
// OnDemand lets the layer surface accept it. Exclusive fights
// the grab it self-clears and instantly closes the panel.
WlrLayershell.keyboardFocus: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
// Vertical bar on the left edge. The surface still covers the
// whole screen (the frame band and every panel are drawn into
// it); only the reserved strip is the bar itself.
anchors {
top: true
left: true
bottom: true
}
implicitWidth: bar.screen.width
exclusiveZone: Theme.barWidth
color: "transparent"
mask: Region {
item: barBgRect
Region {
x: activeDropdown ? activeDropdown.x : 0
y: activeDropdown ? activeDropdown.y : 0
width: activeDropdown && activeDropdown.visible ? activeDropdown.width : 0
height: activeDropdown && activeDropdown.visible ? activeDropdown.height : 0
}
Region {
x: toastItem.visible ? toastItem.x : 0
y: toastItem.visible ? toastItem.y : 0
width: toastItem.visible ? toastItem.width : 0
height: toastItem.visible ? toastItem.height : 0
}
Region {
x: launcherPanel.visible ? launcherPanel.x : 0
y: launcherPanel.visible ? launcherPanel.y : 0
width: launcherPanel.visible ? launcherPanel.width : 0
height: launcherPanel.visible ? launcherPanel.height : 0
}
}
Item {
id: barBgRect
anchors.top: parent.top
anchors.left: parent.left
anchors.bottom: parent.bottom
width: Theme.barWidth
}
// Register the primary bar so shell.qml's IPC handler can
// reach the session menu.
Component.onCompleted: {
if (bar.screen === Quickshell.screens[0])
bar.shellRoot.mainBar = bar;
}
function toggleSession() {
bar.toggleDropdown(sessionMenu, function() {
let pos = powerWidget.mapToItem(bar.contentItem, 0, powerWidget.height / 2);
sessionMenu.dropdownY = pos.y;
});
}
function toggleLauncher() {
launcherPanel.toggle();
}
// Shared base text types: default the shell's two fonts so
// no widget repeats `font.family`. Size/colour/weight are
// overridable per use (these are just the common defaults).
component SText: Text {
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
}
component SIcon: Text {
color: Theme.base05
font.family: Theme.iconFont
font.pixelSize: 16
// No variableAxes here: Lucide.ttf is a static font, so
// FILL/GRAD/opsz (Material Symbols axes) have nothing to
// act on. Stroke weight is fixed by the design.
}
// BarSep: extra whitespace between widget groups. Groups are
// separated by air, not by lines a drawn rule read as clutter
// in a 46px column.
component BarSep: Item {
width: Theme.barWidth
height: 12
}
// GlanceStat: a bar-column stat clock-sized value over a
// faded 9px caption (the server glance rows).
component GlanceStat: Column {
property alias value: _gsV.text
property alias caption: _gsC.text
property color valueColor: Theme.base05
anchors.horizontalCenter: parent.horizontalCenter
spacing: 0
SText {
id: _gsV
anchors.horizontalCenter: parent.horizontalCenter
font.pixelSize: 15
font.weight: Font.Medium
color: parent.valueColor
}
SText {
id: _gsC
anchors.horizontalCenter: parent.horizontalCenter
font.pixelSize: 9
color: Theme.base03
}
}
// VolIcon: a slider's volume glyph that toggles its audio
// node's mute on click. Glyph reflects the mute state; pair it
// with a fill that greys when `audioNode.muted`.
component VolIcon: SIcon {
property var audioNode: null
// Same mapping as the bar widget, so the slider's glyph
// tracks its level and not just the mute flag.
text: !audioNode ? "volume-2"
: audioNode.muted ? "volume-x"
: audioNode.volume >= 0.5 ? "volume-2"
: audioNode.volume > 0 ? "volume-1"
: "volume"
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: if (audioNode) audioNode.muted = !audioNode.muted
}
}
// HoverRow: a rounded clickable row that owns the shared
// base02 hover-fade + pointer cursor. Drop content inside and
// handle `onClicked`; override `radius` for non-radiusTiny rows.
component HoverRow: Rectangle {
default property alias rowData: _hrContent.data
signal clicked()
radius: Theme.radiusTiny
color: _hrMa.containsMouse ? Theme.base02 : Theme.base02t
Behavior on color { ColorAnimation { duration: Theme.animFade } }
Item { id: _hrContent; anchors.fill: parent }
MouseArea {
id: _hrMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
// Card: the rounded base01 section surface used by every
// dropdown. Children flow into a padded auto-height column,
// so callers just set `width` and drop content in.
component Card: Rectangle {
default property alias cardData: _cardCol.data
property alias cardSpacing: _cardCol.spacing
radius: Theme.radiusCard
color: Theme.cardBg
implicitHeight: _cardCol.height + 2 * Theme.cardPad
Column {
id: _cardCol
anchors.top: parent.top
anchors.topMargin: Theme.cardPad
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 2 * Theme.cardPad
spacing: 8
}
}
// PillSlider: rounded track + fill + knob with a full-height
// invisible hit area. `value` is 0..1; `moved(v)` fires on drag.
// Knob diameter derives from the track, so the small app-stream
// variants scale with just a trackH override.
component PillSlider: Item {
property real value: 0
property color fillColor: Theme.base0D
property real trackH: 8
readonly property real knobD: trackH + 8
signal moved(real v)
height: 22
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: parent.trackH
radius: parent.trackH / 2
color: Theme.base02
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.value > 0 ? Math.max(parent.trackH, parent.value * parent.width) : 0
height: parent.trackH
radius: parent.trackH / 2
color: parent.fillColor
Behavior on width { NumberAnimation { duration: 80 } }
}
Rectangle {
width: parent.knobD
height: parent.knobD
radius: parent.knobD / 2
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(parent.width - parent.knobD, parent.value * parent.width - parent.knobD / 2))
color: Theme.base05
Behavior on x { NumberAnimation { duration: 80 } }
}
MouseArea {
anchors.fill: parent
function set(mouse) { parent.moved(Math.max(0, Math.min(1, mouse.x / width))); }
onPressed: (mouse) => set(mouse)
onPositionChanged: (mouse) => { if (pressed) set(mouse); }
}
}
// MeterBar: read-only level readout. PillSlider minus the
// knob and the drag area a monitor row shouldn't look like
// something you can grab.
component MeterBar: Item {
property real value: 0
property color fillColor: Theme.base0D
property real trackH: 5
height: trackH
Rectangle {
anchors.fill: parent
radius: parent.trackH / 2
color: Theme.base02
}
Rectangle {
height: parent.height
width: Math.max(0, Math.min(1, parent.value)) * parent.width
radius: parent.trackH / 2
color: parent.fillColor
Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
}
}
// 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: "moon"; 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.warn
onMoved: (v) => bar.shellRoot.setNightlight(_nlc.nl.autoOn, v)
}
SText {
id: nlTempLabel
width: 38
text: Math.round(6500 - _nlc.nl.warmth * 4000) + "K"
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
}
// NotifContent: summary + body + action chips for one
// notification, shared by the calendar list and the toast.
// Callers supply the container, dismiss button and sizes.
component NotifContent: Column {
id: _nc
property var notif
property int summarySize: 12
property int bodySize: 11
property int bodyLines: 3
property color chipBg: Theme.base01
property color chipBgHover: Theme.base02
property color chipBorder: Theme.base02
signal actionInvoked()
spacing: 2
// App header: icon + name of the sending app, when known.
Row {
width: parent.width
spacing: 4
visible: _nc.notif && (_nc.notif.appName !== "" || _appIcon.source != "")
Image {
id: _appIcon
width: 14; height: 14
sourceSize.width: 14; sourceSize.height: 14
anchors.verticalCenter: parent.verticalCenter
source: _nc.notif && _nc.notif.appIcon ? Quickshell.iconPath(_nc.notif.appIcon, true) : ""
visible: source != ""
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: _nc.notif ? _nc.notif.appName : ""
color: Theme.base04
font.pixelSize: 10
elide: Text.ElideRight
width: parent.width - (_appIcon.visible ? 18 : 0)
}
}
SText {
width: parent.width
text: _nc.notif ? (_nc.notif.summary || _nc.notif.appName) : ""
font.pixelSize: _nc.summarySize
font.weight: Font.Medium
elide: Text.ElideRight
}
SText {
width: parent.width
text: _nc.notif ? (_nc.notif.body || "") : ""
color: Theme.base04
font.pixelSize: _nc.bodySize
elide: Text.ElideRight
maximumLineCount: _nc.bodyLines
wrapMode: Text.Wrap
// ponytail: hyprshot's body is a noisy file path; the
// "Screenshot saved" summary + the preview say enough.
visible: text !== "" && !(_nc.notif && _nc.notif.appName === "Hyprshot")
}
Row {
spacing: 4
visible: _nc.notif && _nc.notif.actions.length > 0
Repeater {
model: _nc.notif ? _nc.notif.actions : []
Rectangle {
required property var modelData
width: _at.width + 12
height: _at.height + 6
radius: Theme.radiusTiny
color: _ama.containsMouse ? _nc.chipBgHover : _nc.chipBg
Behavior on color { ColorAnimation { duration: Theme.animFade } }
border.width: 1
border.color: _nc.chipBorder
SText { id: _at; anchors.centerIn: parent; text: modelData.text; font.pixelSize: 10 }
MouseArea {
id: _ama
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { modelData.invoke(); _nc.actionInvoked(); }
}
}
}
}
}
// Shell chrome: bar, frame, panel and toast rendered as
// ONE signed-distance field (caelestia-style). Surfaces merge
// via circular smooth-min, and the 2px border is the distance
// band just inside the boundary borders flow through every
// junction fillet by construction, so all of the previous
// ears / border-gaps / melt geometry lives in the math now.
ShaderEffect {
anchors.fill: parent
// Panels grow rightward out of the bar column, so the
// cross-axis is now vertical: the 8px inset that used to
// be left/right is top/bottom.
readonly property real rawTop: chrome.y + 8
readonly property real rawBottom: chrome.y + chrome.height - 8
// How deep a surface buries itself in the band it merges
// with. This MUST be >= the panel corner radius. A rounded
// corner curves away from the edge over exactly panelR
// pixels, so burying less than that leaves part of the
// curve sticking out past the joint: the outline pinches in
// where the corner turns, then the fillet bulges back out
// the "ballooned at the connection point" look. Bury the
// whole corner and only straight edges reach the junction,
// which is what smin is meant to fillet.
readonly property real meltInto: Theme.radius
readonly property real meltTop: Theme.frameWidth - meltInto
readonly property real meltBot: bar.height - Theme.frameWidth + meltInto
// Trigger distance is about proximity, not burial depth.
readonly property real snapDist: Theme.panelGap + 4
readonly property real panelTop: rawTop <= Theme.frameWidth + snapDist
? meltTop : rawTop
readonly property real panelBottom: rawBottom >= bar.height - Theme.frameWidth - snapDist
? meltBot : rawBottom
// Left edge lands at (barWidth meltInto): the surface
// buries a full corner radius inside the bar column.
readonly property real surfLeftX: Theme.barWidth - meltInto / 2
property vector4d cutout: Qt.vector4d(
(Theme.barWidth + bar.width - Theme.frameWidth) / 2,
bar.height / 2,
(bar.width - Theme.frameWidth - Theme.barWidth) / 2,
bar.height / 2 - Theme.frameWidth)
property vector4d panel: chrome.visible
? Qt.vector4d(surfLeftX + chrome.width / 2, (panelTop + panelBottom) / 2,
meltInto / 2 + chrome.width / 2, (panelBottom - panelTop) / 2)
: Qt.vector4d(0, 0, 0, 0)
// Toast sits under the top frame band and melts up into it.
readonly property real toastBot: toastItem.y + 8 + _toastRect.height
property vector4d toast: toastItem.visible && _toastRect.width > 0.5
? Qt.vector4d(surfLeftX + _toastRect.width / 2, (meltTop + toastBot) / 2,
meltInto / 2 + _toastRect.width / 2, (toastBot - meltTop) / 2)
: Qt.vector4d(0, 0, 0, 0)
// The launcher is the one surface that stays put: it still
// grows from the bottom frame band at screen centre.
readonly property real launchBot: bar.height - Theme.frameWidth + meltInto
property vector4d launcher: launcherPanel.visible
? Qt.vector4d(launcherPanel.x + launcherPanel.width / 2, (launcherPanel.y + launchBot) / 2,
launcherPanel.width / 2, (launchBot - launcherPanel.y) / 2)
: Qt.vector4d(0, 0, 0, 0)
property vector4d fillColor: Qt.vector4d(Theme.barBg.r, Theme.barBg.g, Theme.barBg.b, Theme.barBg.a)
property vector4d borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1)
property vector2d res: Qt.vector2d(width, height)
property real cutoutR: Theme.radius
property real panelR: Theme.radius
// Half the corner radius: the fillet scales with the
// one rounding knob instead of drifting out of step.
property real meltK: Theme.radius / 2
property real borderW: Theme.borderWidth
fragmentShader: "file://${chromeShader}"
}
// Launcher: rises out of the bottom frame edge (Super+R).
// Lives in the SDF field, so it melts into the frame and its
// height morphs live as results filter. Opens on a category
// browser; typing switches to fuzzy search. Results live in a
// ListModel that's DIFFED against each new filter pass, so
// surviving rows slide to their new rank (ListView move
// transitions) instead of the whole list repopulating.
Item {
id: launcherPanel
property bool open: false
readonly property real panelW: 520
// Cap the visible list at 8 rows; longer lists scroll.
readonly property real listCapH: 8 * 56
property real targetH: launcherCol.height + 24
// Both axes animate: expands from a small point on the
// bottom edge (like the top dropdowns' stub seed).
property real openH: open ? targetH : 0
property real openW: open ? panelW : 80
Behavior on openH {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
Behavior on openW {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
x: Math.round((bar.width - openW) / 2)
y: bar.height - Theme.frameWidth - openH
width: openW
height: openH
visible: openH > 0.5
property string activeCat: ""
property var entries: []
// Right-click menu target "" means closed.
property string menuAppId: ""
property real menuX: 0
property real menuY: 0
// Non-empty when the query parses as arithmetic.
property string calcResult: ""
readonly property var catDefs: [
{ key: "All", label: "All apps", icon: "layout-grid" },
{ key: "Network", label: "Internet", icon: "globe" },
{ key: "Development", label: "Development", icon: "code" },
{ key: "Game", label: "Games", icon: "gamepad-2" },
{ key: "AudioVideo", label: "Media", icon: "circle-play" },
{ key: "Graphics", label: "Graphics", icon: "palette" },
{ key: "Office", label: "Office", icon: "file-text" },
{ key: "System", label: "System", icon: "monitor" },
{ key: "Utility", label: "Utilities", icon: "blocks" },
{ key: "Other", label: "Other", icon: "ellipsis" }
]
function inCat(a, key) {
if (key === "All") return true;
const c = a.categories;
if (key === "System") return c.indexOf("System") !== -1 || c.indexOf("Settings") !== -1;
if (key === "Other") {
const main = ["Network", "Development", "Game", "AudioVideo", "Graphics", "Office", "System", "Settings", "Utility"];
for (let i = 0; i < main.length; i++)
if (c.indexOf(main[i]) !== -1) return false;
return true;
}
return c.indexOf(key) !== -1;
}
function catCount(key) {
return DesktopEntries.applications.values.filter(a => !a.noDisplay && inCat(a, key)).length;
}
function catLabel(key) {
const d = catDefs.find(x => x.key === key);
return d ? d.label : key;
}
function toggle() {
open = !open;
if (open) {
searchInput.text = "";
activeCat = "";
refilter();
searchInput.forceActiveFocus();
}
}
// Results are a single flat list of heterogeneous items
// apps, then files, then a web-search fallback so one set
// of arrow keys walks all of it and there's no focus to
// shuffle between sections. Every item carries a `kind` and
// the same display fields; `activate` dispatches on kind.
function activate(item) {
if (!item) return;
if (item.kind === "app") item.app.execute();
else if (item.kind === "file") Quickshell.execDetached([Commands.xdgOpen, item.path]);
else if (item.kind === "web") Quickshell.execDetached([Commands.xdgOpen, item.url]);
else {
// Shout rather than no-op. An unwrapped DesktopEntry
// reaching here is a caller bug, and the silent
// version of it cost an afternoon: the pin tiles
// looked fine and simply never launched anything.
console.warn("launcher: activate() got no usable kind:", JSON.stringify(item));
return;
}
open = false;
}
function appItem(a) {
return {
kind: "app",
id: a.id,
name: a.name,
desc: a.genericName !== "" ? a.genericName : a.comment,
icon: Quickshell.iconPath(a.icon, true) || "",
glyph: "",
app: a
};
}
// Enter takes the calculator only when there's nothing to
// launch, so "3+3" copies the answer.
function submit() {
if (calcResult !== "" && entries.length === 0) copyCalc();
else activate(entries[launcherList.currentIndex]);
}
function copyCalc() {
if (calcResult === "") return;
Quickshell.execDetached([Commands.wlCopy, calcResult]);
open = false;
}
// File search: one fd run per settled keystroke, scoped to
// $HOME and nothing above it. No index and no daemon
// --max-results lets fd exit the moment it has enough, which
// is what keeps this feeling instant, and results are never
// stale the way a nightly updatedb is.
property var fileHits: []
property string fdQuery: ""
// A run cut short still flushes whatever stdout it had, and
// those lines are a half-finished search. Flag that one
// flush to be thrown away.
property bool fdDropNext: false
readonly property int fdMin: 3 // shorter queries match everything
readonly property int fdMax: 6 // rows to keep
Timer {
id: fdTimer
interval: 200
onTriggered: launcherPanel.runFd()
}
Process {
id: fdProc
stdout: StdioCollector {
onStreamFinished: launcherPanel.takeFiles(text)
}
}
function killFd() {
if (!fdProc.running) return;
fdDropNext = true;
fdProc.running = false;
}
function runFd() {
const q = searchInput.text.trim();
if (q.length < fdMin) {
killFd();
fileHits = [];
rebuild(false);
return;
}
// Spaces become gaps, so "report 2024" finds
// report-2024.pdf. Everything else is escaped, so a
// query with brackets in it can't make fd bail.
const pat = q.replace(/[.^$*+?()[\]{}|\\]/g, "\\$&").replace(/\s+/g, ".*");
fdQuery = q;
// Only one fd at a time. Assigning command/running while
// the old one is still dying is deliberate quickshell
// re-runs startProcessIfReady() from onFinished, so the
// new command starts as soon as the old process reaps.
killFd();
fdProc.command = [
Commands.fd, "--absolute-path", "--ignore-case", "--hidden",
"--type", "f", "--type", "d",
"--max-results", String(fdMax),
"--exclude", ".git", "--exclude", ".cache", "--exclude", "node_modules",
"--exclude", ".local/share/Steam", "--exclude", ".steam",
pat, Quickshell.env("HOME")
];
fdProc.running = true;
}
function takeFiles(text) {
if (fdDropNext) { fdDropNext = false; return; }
// A run that finished after the query moved on is junk.
if (fdQuery !== searchInput.text.trim()) return;
const home = Quickshell.env("HOME");
const lines = text.split("\n").filter(l => l !== "");
let out = [];
for (let i = 0; i < lines.length && i < fdMax; i++) {
const raw = lines[i];
const isDir = raw.endsWith("/"); // fd marks dirs
const p = isDir ? raw.slice(0, -1) : raw;
const cut = p.lastIndexOf("/");
const dir = p.slice(0, cut);
out.push({
kind: "file",
id: "file:" + p,
name: p.slice(cut + 1),
desc: dir.startsWith(home) ? "~" + dir.slice(home.length) : dir,
icon: "",
glyph: isDir ? "folder" : "file",
path: p
});
}
fileHits = out;
rebuild(false);
}
// Instant answers: Wikipedia's article intro for queries
// that read like a question rather than an app name.
//
// This sends the query to Wikipedia, so it deliberately does
// NOT fire per keystroke long debounce, a shape test that
// skips app-sized queries, and a cache of what's already been
// asked. Bursts get throttled (empty 202/4xx) anyway, so
// restraint is correctness here, not just politeness.
property string answerFact: ""
property string answerLead: ""
property string answerTitle: ""
property string answerUrl: ""
property string answerQuery: ""
property string answerSource: ""
property bool answerDropNext: false
property bool askDropNext: false
// Claude answered the current query, so the slower-arriving
// Wikipedia result must not overwrite it.
property bool askWon: false
property var answerCache: ({})
readonly property var answerStop: [
"how", "what", "when", "where", "who", "whom", "why", "which",
"is", "are", "was", "were", "be", "the", "a", "an", "of", "in",
"on", "at", "to", "for", "and", "or", "does", "do", "did",
"much", "many", "its", "it"
]
// Asking for one of these means the answer is a number, which
// is what makes a digit in the sentence worth points.
readonly property var answerAttrs: [
"height", "tall", "high", "population", "long", "length",
"deep", "depth", "old", "age", "born", "died", "weight",
"heavy", "distance", "far", "area", "size", "wide", "width",
"speed", "fast", "temperature", "cost", "price", "year",
"when", "many", "much", "capital", "big"
]
// What a sentence answering THIS kind of question looks like.
// A digit is the tell for "how tall"; for "where" it's the
// vocabulary of place. Stems, not whole words the Telephone
// intro says "granted a United States patent", never
// "invented", so "patent" has to match "patent" in either.
readonly property var answerHints: ({
where: ["located", "lies", "situated", "region", "country",
"border", "city", "province", "state", "range",
"north", "south", "east", "west", "coast", "island", "near"],
when: ["century", "founded", "established", "opened",
"began", "completed", "built", "inaugurated"],
// Deliberately NOT "is a"/"was a": those match the
// definitional opening of every article.
who: ["invent", "discover", "found", "creat", "develop",
"credit", "patent", "known for", "named after"]
})
// "firefox" and "vs code" are app searches; "mount everest
// height" is a question. Two words plus some length sorts
// them, and a "?" anywhere forces a lookup either way.
function wantsAnswer(raw) {
const q = raw.trim();
if (q === "" || calcResult !== "") return false;
if (q.indexOf("?") !== -1) return true;
return q.split(/\s+/).length >= 2 && q.length >= 8;
}
// The "?" is a trigger, not a search term.
function answerKey(s) {
return s.replace(/\?/g, " ").replace(/\s+/g, " ").trim();
}
Timer {
id: answerTimer
interval: 600
onTriggered: launcherPanel.runAnswer()
}
Process {
id: answerProc
stdout: StdioCollector {
onStreamFinished: launcherPanel.takeAnswer(text)
}
}
// Claude, tried first. The script is the feature gate: with
// no API key it exits immediately and prints nothing, which
// is the same signal as a failed call so the Wikipedia
// path stays the default with zero configuration.
Process {
id: askProc
stdout: StdioCollector {
onStreamFinished: launcherPanel.takeAsk(text)
}
}
function killAnswer() {
if (!answerProc.running) return;
answerDropNext = true;
answerProc.running = false;
}
function killAsk() {
if (!askProc.running) return;
askDropNext = true;
askProc.running = false;
}
function clearAnswer() {
answerTimer.stop();
killAnswer();
killAsk();
askWon = false;
answerFact = "";
answerLead = "";
answerTitle = "";
answerUrl = "";
answerSource = "";
}
function runAnswer() {
const q = answerKey(searchInput.text);
if (q === "" || !wantsAnswer(searchInput.text)) {
clearAnswer();
return;
}
answerQuery = q;
const hit = answerCache[q];
if (hit !== undefined) {
showAnswer(hit);
return;
}
// Both at once. Wikipedia fills the card in well under a
// second; Claude takes ~6s through Claude Code and
// supersedes it on arrival. Racing them beats chaining:
// Claude-then-fallback would leave the card blank for
// six seconds on every question.
askWon = false;
killAsk();
askProc.command = [Commands.askFetch, q];
askProc.running = true;
startWiki();
}
function startWiki() {
killAnswer();
answerProc.command = [Commands.answerFetch, answerQuery];
answerProc.running = true;
}
function takeAsk(raw) {
if (askDropNext) { askDropNext = false; return; }
const text = raw.trim();
// Nothing to add whatever Wikipedia found stands.
if (text === "") return;
const res = {
title: "", // no article behind it
fact: text.slice(0, 400),
lead: "",
url: "", // nothing to open
source: "Claude"
};
cacheAnswer(res);
// Typing may have moved on while the call was out.
if (answerQuery !== answerKey(searchInput.text)) return;
askWon = true;
showAnswer(res);
}
function cacheAnswer(res) {
if (Object.keys(answerCache).length > 40) answerCache = ({});
answerCache[answerQuery] = res;
}
function showAnswer(res) {
answerTitle = res.title;
answerFact = res.fact;
answerLead = res.lead;
answerUrl = res.url;
answerSource = res.source;
}
// Wikipedia hands back a whole intro; the sentence that
// actually answers the question is often buried in it
// ("The tower is 330 metres tall" is nine sentences down).
// So score every sentence and lead with the best one:
// +3 per query term present
// +4 for containing a digit when a number was asked for
// ties keep the earliest, which favours the definitional
// sentence over a later aside that restates the subject.
// Title words are dropped from the terms otherwise a
// sentence that merely repeats "Eiffel Tower" outscores the
// one with the height EXCEPT when the title word is the
// attribute being asked about ("Lunar distance" for "distance
// to the moon"), where it's the whole question.
function pickFact(extract, query, title) {
// Alias and pronunciation parentheticals are long and
// bury the fact. Short ones like "(29,031 ft)" ARE the
// fact, so only the long ones go.
const body = extract.replace(/\s*\([^()]{25,}\)/g, "").replace(/\s+/g, " ").trim();
if (body === "") return { fact: "", lead: "" };
// Sentence split by lookahead only V4 has no lookbehind.
// Marking the break keeps the full stop on the sentence
// rather than consuming it as the delimiter.
const sep = String.fromCharCode(1);
const sents = body.replace(/([.!?])\s+(?=[A-Z"'])/g, "$1" + sep)
.split(sep)
.filter(s => s.length > 20);
if (sents.length === 0) return { fact: body.slice(0, 260), lead: "" };
const ql = query.toLowerCase();
const tw = title.toLowerCase().split(/[^a-z0-9]+/);
const words = ql.split(/[^a-z0-9]+/);
// 2 chars, not 3: "K2" and "UK" are real search terms.
const terms = words.filter(w =>
w.length >= 2 && answerStop.indexOf(w) === -1
&& (answerAttrs.indexOf(w) !== -1 || tw.indexOf(w) === -1));
const wantsNumber = answerAttrs.some(a => ql.indexOf(a) !== -1);
// "where is K2" carries no scoring terms at all once the
// stopword and the title word are gone the answer-type
// hints are the only signal it has.
let hints = [];
for (const k in answerHints)
if (words.indexOf(k) !== -1) hints = hints.concat(answerHints[k]);
let best = 0;
let bestScore = -1;
for (let i = 0; i < sents.length; i++) {
const s = sents[i].toLowerCase();
let sc = 0;
for (let t = 0; t < terms.length; t++)
if (s.indexOf(terms[t]) !== -1) sc += 3;
if (wantsNumber && /\d/.test(sents[i])) sc += 4;
if (hints.some(h => s.indexOf(h) !== -1)) sc += 4;
if (sc > bestScore) { bestScore = sc; best = i; }
}
return {
fact: sents[best].slice(0, 260),
// Context for a fact pulled from mid-article. Nothing
// to add when the fact IS the opening line.
lead: best === 0 ? "" : sents[0].slice(0, 170)
};
}
// Which of the three candidates the query actually meant.
// Search rank alone is not enough: "chernobyl" ranks the HBO
// miniseries above the city.
function pickPage(pages, query) {
const ql = query.toLowerCase();
const content = ql.split(/[^a-z0-9]+/)
.filter(w => w.length >= 2 && answerStop.indexOf(w) === -1)
.join(" ");
// A title that IS the query beats whatever ranked first.
const exact = pages.find(p => (p.title || "").toLowerCase() === content);
if (exact !== undefined) return exact;
// Otherwise skip a disambiguated title unless the query
// asked for that sense ("chernobyl miniseries").
const plain = pages.find(p => {
const m = (p.title || "").match(/\(([^)]+)\)/);
return m === null || ql.indexOf(m[1].toLowerCase()) !== -1;
});
return plain !== undefined ? plain : pages[0];
}
function takeAnswer(raw) {
if (answerDropNext) { answerDropNext = false; return; }
let title = "";
let extract = "";
try {
const obj = JSON.parse(raw).query.pages;
let pages = [];
for (const k in obj) pages.push(obj[k]); // keyed by pageid
pages.sort((a, b) => a.index - b.index); // restore search order
const p = pickPage(pages, answerQuery);
title = p.title || "";
extract = p.extract || "";
} catch (e) {
title = ""; // no match, or a throttled empty reply
extract = "";
}
const picked = title === "" ? { fact: "", lead: "" }
: pickFact(extract, answerQuery, title);
const res = {
title: title,
fact: picked.fact,
lead: picked.lead,
url: title === "" ? ""
: "https://en.wikipedia.org/wiki/" + encodeURIComponent(title.replace(/ /g, "_")),
source: "Wikipedia"
};
// Claude already answered this query don't overwrite
// the better answer in the cache or on screen.
if (askWon) return;
cacheAnswer(res);
// Typing may have moved on while curl was out.
if (answerQuery !== answerKey(searchInput.text)) return;
showAnswer(res);
}
// Web search: last row, always. A leading bang retargets
// it; anything else goes to the default engine.
readonly property string webEngine: "https://duckduckgo.com/?q="
readonly property var bangs: [
{ b: "!yt", label: "YouTube", glyph: "youtube", url: "https://www.youtube.com/results?search_query=" },
{ b: "!gh", label: "GitHub", glyph: "github", url: "https://github.com/search?q=" },
{ b: "!no", label: "NixOS options", glyph: "package", url: "https://search.nixos.org/options?query=" },
{ b: "!np", label: "nixpkgs", glyph: "package", url: "https://search.nixos.org/packages?query=" },
{ b: "!w", label: "Wikipedia", glyph: "book-open", url: "https://en.wikipedia.org/w/index.php?search=" }
]
function webItem(raw) {
const q = raw.trim();
// Arithmetic is an answer, not a search term leave the
// row off so keeps copying the result.
if (q === "" || calcResult !== "") return null;
const sp = q.indexOf(" ");
const head = (sp === -1 ? q : q.slice(0, sp)).toLowerCase();
const bang = bangs.find(x => x.b === head);
const term = bang ? q.slice(sp === -1 ? q.length : sp + 1).trim() : q;
if (term === "") return null; // a bare "!yt" has nothing to search yet
return {
kind: "web",
id: "web",
name: term,
desc: "Search " + (bang ? bang.label : "the web"),
icon: "",
glyph: bang ? bang.glyph : "globe",
url: (bang ? bang.url : webEngine) + encodeURIComponent(term)
};
}
// Pinned apps: a 3-up tile grid above the results, in the
// order the ids are stored. Stored as one comma-joined
// string .desktop ids never contain commas, and a plain
// string is what JsonAdapter is guaranteed to round-trip.
FileView {
id: pinFile
path: Quickshell.env("HOME") + "/.local/state/quickshell-launcher-pins.json"
printErrors: false
adapter: JsonAdapter {
id: pinState
property string ids: ""
}
onLoadFailed: writeAdapter()
}
function pinIds() {
return pinState.ids.split(",").filter(x => x !== "");
}
function isPinned(id) {
return id !== "" && pinIds().indexOf(id) !== -1;
}
function togglePin(id) {
const cur = pinIds();
const next = cur.filter(x => x !== id);
if (next.length === cur.length) next.unshift(id); // wasn't pinned
pinState.ids = next.join(",");
pinFile.writeAdapter();
refilter();
}
// Backing model for the tile grid. Drops ids whose .desktop
// file is gone (app uninstalled since it was pinned).
ListModel { id: pinModel }
function syncPins() {
const all = DesktopEntries.applications.values;
pinModel.clear();
const ids = pinIds();
for (let i = 0; i < ids.length; i++) {
const a = all.find(x => x.id === ids[i]);
if (a === undefined) continue;
pinModel.append({
appId: a.id,
name: a.name,
icon: Quickshell.iconPath(a.icon, true) || ""
});
}
}
// Reorder from a drag. Rewrites the whole id list from the
// model, so a pin whose .desktop vanished is pruned here too.
function movePin(from, to) {
if (to === from || from < 0 || to < 0
|| from >= pinModel.count || to >= pinModel.count) return;
pinModel.move(from, to, 1);
let ids = [];
for (let i = 0; i < pinModel.count; i++) ids.push(pinModel.get(i).appId);
pinState.ids = ids.join(",");
pinFile.writeAdapter();
}
// Must go through appItem(): activate() dispatches on `kind`,
// so handing it a bare DesktopEntry silently does nothing.
function launchPin(id) {
const app = DesktopEntries.applications.values.find(a => a.id === id);
if (app === undefined) return;
activate(appItem(app));
}
// App matches for the current query, kept apart from
// fileHits so a late fd result can be merged in without
// rescoring every desktop entry again.
property var appHits: []
function refilter() {
let q = searchInput.text.toLowerCase().trim();
calcResult = calc(searchInput.text);
menuAppId = "";
syncPins();
let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay);
if (q === "") {
let list = activeCat === "" ? [] : apps.filter(a => inCat(a, activeCat));
list.sort((a, b) => a.name.localeCompare(b.name));
appHits = list.map(a => appItem(a));
// Nothing typed: no files to look for, nothing to ask.
fdTimer.stop();
killFd();
fileHits = [];
clearAnswer();
} else {
let scored = [];
for (let i = 0; i < apps.length; i++) {
let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q);
// Pins get a boost, not a veto: an exact prefix
// match on an unpinned app still wins.
if (s > 0 && isPinned(apps[i].id)) s += 2;
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));
appHits = scored.slice(0, 8).map(x => appItem(x.app));
fdTimer.restart();
// A previous answer stays put while the next one is in
// flight it's captioned with its own article title,
// so a lagging card reads as stale, not as wrong.
// Clearing per keystroke just made it blink.
if (wantsAnswer(searchInput.text)) answerTimer.restart();
else clearAnswer();
}
rebuild(true);
}
// Merge the three sources into the one list. `reset` moves
// the highlight home; a late fd result passes false so it
// can't yank the selection out from under the arrow keys.
function rebuild(reset) {
let list = appHits.concat(fileHits);
const w = webItem(searchInput.text);
if (w !== null) list.push(w);
entries = list;
syncModel(list);
if (reset || launcherList.currentIndex >= list.length)
launcherList.currentIndex = list.length > 0 ? 0 : -1;
}
// Diff the new result list into the ListModel instead of
// resetting it: surviving rows MOVE (animated), dropped
// rows fade out, new rows fade in. A plain array model
// would rebuild every delegate on each keystroke.
function syncModel(list) {
const m = launcherModel;
const ids = {};
for (let i = 0; i < list.length; i++) ids[list[i].id] = true;
for (let i = m.count - 1; i >= 0; i--)
if (!ids[m.get(i).appId]) m.remove(i);
for (let i = 0; i < list.length; i++) {
const a = list[i];
let j = -1;
for (let k = i; k < m.count; k++)
if (m.get(k).appId === a.id) { j = k; break; }
if (j === -1) {
m.insert(i, {
appId: a.id,
kind: a.kind,
name: a.name,
icon: a.icon,
desc: a.desc,
glyph: a.glyph,
pinned: a.kind === "app" && isPinned(a.id)
});
} else if (j !== i) {
m.move(j, i, 1);
}
}
while (m.count > list.length) m.remove(m.count - 1);
// Surviving rows are moved, not rebuilt, so their mutable
// fields are refreshed in place. The web row keeps one
// stable id on purpose its text changes every keystroke
// and a new id would make it flicker.
for (let i = 0; i < m.count; i++) {
m.setProperty(i, "name", list[i].name);
m.setProperty(i, "desc", list[i].desc);
m.setProperty(i, "glyph", list[i].glyph);
m.setProperty(i, "pinned", list[i].kind === "app" && isPinned(list[i].id));
}
}
ListModel { id: launcherModel }
function score(name, extra, q) {
let n = name.toLowerCase();
if (n.startsWith(q)) return 5;
if (n.includes(" " + q)) return 4;
if (n.includes(q)) return 3;
if (extra && extra.toLowerCase().includes(q)) return 2;
// Fuzzy: q as an in-order subsequence of n (vktop
// vesktop); fewer skipped characters scores higher.
let qi = 0, gaps = 0, last = -1;
for (let i = 0; i < n.length && qi < q.length; i++) {
if (n[i] === q[qi]) {
if (last >= 0) gaps += i - last - 1;
last = i;
qi++;
}
}
if (qi === q.length) return 1 / (1 + gaps);
return 0;
}
// Calculator: type "3+3", get 6. A hand-rolled
// recursive-descent parser rather than eval(), so the query
// never reaches the JS engine as code. Precedence:
// ^ (right-assoc) > unary sign > * / % > + -.
// Returns "" for anything that isn't arithmetic, which is
// also how the result card decides to stay hidden.
function calc(raw) {
const q = raw.trim();
if (q === "") return "";
if (!/^[-+*\/%^().\d\s]+$/.test(q)) return "";
if (!/[-+*\/%^]/.test(q)) return ""; // a bare number is not a sum
try {
const v = calcParse(q);
if (typeof v !== "number" || !isFinite(v)) return "";
return String(Math.round(v * 1e8) / 1e8);
} catch (e) {
return ""; // half-typed expression: show nothing
}
}
function calcParse(s) {
let i = 0;
function ws() { while (s[i] === " ") i++; }
function atom() {
ws();
if (s[i] === "(") {
i++;
const v = add();
ws();
if (s[i] !== ")") throw 1;
i++;
return v;
}
if (s[i] === "-") { i++; return -pow(); }
if (s[i] === "+") { i++; return pow(); }
const st = i;
while (i < s.length && ((s[i] >= "0" && s[i] <= "9") || s[i] === ".")) i++;
if (i === st) throw 1;
const n = parseFloat(s.slice(st, i));
if (isNaN(n)) throw 1;
return n;
}
function pow() {
const b = atom();
ws();
if (s[i] === "^") { i++; return Math.pow(b, pow()); }
return b;
}
function mul() {
let v = pow();
for (;;) {
ws();
const c = s[i];
if (c !== "*" && c !== "/" && c !== "%") return v;
i++;
const r = pow();
v = c === "*" ? v * r : c === "/" ? v / r : v % r;
}
}
function add() {
let v = mul();
for (;;) {
ws();
const c = s[i];
if (c !== "+" && c !== "-") return v;
i++;
const r = mul();
v = c === "+" ? v + r : v - r;
}
}
const out = add();
ws();
if (i !== s.length) throw 1; // trailing junk
return out;
}
// Content anchored to the bottom so the grow reveals upward
Item {
anchors.fill: parent
clip: true
opacity: launcherPanel.open ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
}
Column {
id: launcherCol
anchors.bottom: parent.bottom
anchors.bottomMargin: 12
anchors.horizontalCenter: parent.horizontalCenter
width: launcherPanel.panelW - 24
spacing: 8
// Calculator result: appears the moment the query
// parses as arithmetic. Click (or ) copies it.
Rectangle {
width: parent.width
height: 54
radius: Theme.radiusCard
color: Theme.cardBg
visible: launcherPanel.calcResult !== ""
Row {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 10
SIcon {
anchors.verticalCenter: parent.verticalCenter
text: "equal"
font.pixelSize: 18
color: Theme.base0D
}
SText {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 28 - 20 - copyHint.width
text: launcherPanel.calcResult
font.pixelSize: 20
font.weight: Font.Medium
elide: Text.ElideRight
}
SText {
id: copyHint
anchors.verticalCenter: parent.verticalCenter
text: "copy"
font.pixelSize: 11
color: Theme.base04
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: launcherPanel.copyCalc()
}
}
// Instant answer. A fact isn't something you launch,
// so it's a card like the calculator's rather than a
// row in the results list and it needs the height
// to wrap. Click opens the article.
Rectangle {
width: parent.width
height: answerCol.height + 28
radius: Theme.radiusCard
color: Theme.cardBg
visible: launcherPanel.answerFact !== ""
Column {
id: answerCol
anchors.top: parent.top
anchors.topMargin: 14
anchors.left: parent.left
anchors.leftMargin: 14
anchors.right: parent.right
anchors.rightMargin: 14
spacing: 4
// Article title Wikipedia only; a Claude
// answer has no page behind it.
Row {
spacing: 8
visible: launcherPanel.answerTitle !== ""
SIcon {
anchors.verticalCenter: parent.verticalCenter
text: "sparkles"
font.pixelSize: 14
color: Theme.base0D
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: launcherPanel.answerTitle
font.pixelSize: 13
font.weight: Font.Medium
}
}
// The answer itself, then the article's
// opening line as context omitted when the
// answer already came from it.
SText {
width: parent.width
text: launcherPanel.answerFact
font.pixelSize: 13
wrapMode: Text.WordWrap
}
SText {
width: parent.width
visible: launcherPanel.answerLead !== ""
text: launcherPanel.answerLead
font.pixelSize: 11
color: Theme.base04
wrapMode: Text.WordWrap
}
// Always say who answered the two sources
// carry very different trust.
SText {
text: launcherPanel.answerSource
font.pixelSize: 10
color: Theme.base03
}
}
// Only Wikipedia answers have somewhere to go.
MouseArea {
anchors.fill: parent
enabled: launcherPanel.answerUrl !== ""
cursorShape: Qt.PointingHandCursor
onClicked: {
Quickshell.execDetached([Commands.xdgOpen, launcherPanel.answerUrl]);
launcherPanel.open = false;
}
}
}
// Pinned tiles, three across. Drag a tile onto
// another slot to reorder the id list is rewritten
// on release, not mid-drag, so nothing jitters under
// the cursor while it moves.
Rectangle {
width: parent.width
height: pinGrid.height + 2 * Theme.cardPad
radius: Theme.radiusCard
color: Theme.cardBg
visible: pinModel.count > 0 && searchInput.text === ""
&& launcherPanel.activeCat === ""
Grid {
id: pinGrid
anchors.top: parent.top
anchors.topMargin: Theme.cardPad
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 2 * Theme.cardPad
columns: 3
columnSpacing: 8
rowSpacing: 8
readonly property real cellW: (width - (columns - 1) * columnSpacing) / columns
readonly property real cellH: 66
// Which slot a dragged tile's centre is over.
function slotAt(px, py) {
const c = Math.max(0, Math.min(columns - 1,
Math.floor(px / (cellW + columnSpacing))));
const r = Math.max(0, Math.floor(py / (cellH + rowSpacing)));
return Math.max(0, Math.min(pinModel.count - 1, r * columns + c));
}
move: Transition {
NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo }
}
Repeater {
model: pinModel
// Cell stays where the Grid puts it; the
// tile inside is what the drag moves.
Item {
id: pinCell
required property string appId
required property string name
required property string icon
required property int index
width: pinGrid.cellW
height: pinGrid.cellH
z: pinMa.dragging ? 10 : 0
Rectangle {
id: pinTile
width: pinCell.width
height: pinCell.height
radius: Theme.radiusTiny
color: pinMa.containsMouse || pinMa.dragging ? Theme.base02 : Theme.base02t
Behavior on color { ColorAnimation { duration: Theme.animFade } }
// Snap home; a reorder has already
// moved the cell out from under it.
Behavior on x { enabled: !pinMa.dragging; NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutExpo } }
Behavior on y { enabled: !pinMa.dragging; NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutExpo } }
Column {
anchors.centerIn: parent
spacing: 4
Image {
anchors.horizontalCenter: parent.horizontalCenter
visible: source != ""
width: 28
height: 28
sourceSize.width: 28
sourceSize.height: 28
source: pinCell.icon
}
SText {
width: pinCell.width - 12
text: pinCell.name
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
MouseArea {
id: pinMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
drag.target: pinTile
drag.threshold: 6
// Latched for the whole press so
// the click that ends a drag can
// be told apart from a real click.
property bool dragging: false
drag.onActiveChanged: if (drag.active) dragging = true
onPressed: dragging = false
onReleased: {
if (dragging) {
launcherPanel.movePin(pinCell.index, pinGrid.slotAt(
pinCell.x + pinTile.x + pinTile.width / 2,
pinCell.y + pinTile.y + pinTile.height / 2));
dragging = false;
}
pinTile.x = 0;
pinTile.y = 0;
}
onClicked: (mouse) => {
if (dragging) return;
if (mouse.button === Qt.RightButton) {
const p = mapToItem(launcherPanel, mouse.x, mouse.y);
launcherPanel.menuX = p.x;
launcherPanel.menuY = p.y;
launcherPanel.menuAppId = pinCell.appId;
} else {
launcherPanel.launchPin(pinCell.appId);
}
}
}
}
}
}
}
}
// Category-browse breadcrumb: back to the grid.
HoverRow {
width: parent.width
height: 30
visible: launcherPanel.activeCat !== "" && searchInput.text === ""
onClicked: { launcherPanel.activeCat = ""; launcherPanel.refilter(); }
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 10
spacing: 8
SIcon {
anchors.verticalCenter: parent.verticalCenter
text: "arrow-left"
font.pixelSize: 16
color: Theme.base04
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: launcherPanel.catLabel(launcherPanel.activeCat)
font.weight: Font.Medium
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: launcherPanel.entries.length + " apps"
font.pixelSize: 11
color: Theme.base04
}
}
}
// Results sit in a base01 card segment, like the
// notification list and the other dropdowns.
Rectangle {
width: parent.width
height: Math.min(launcherList.contentHeight, launcherPanel.listCapH) + 2 * Theme.cardPad
radius: Theme.radiusCard
color: Theme.cardBg
visible: launcherList.count > 0
ListView {
id: launcherList
anchors.fill: parent
anchors.margins: Theme.cardPad
clip: true
interactive: contentHeight > height
model: launcherModel
highlight: Rectangle {
radius: Theme.radiusSmall
color: Theme.base02
}
highlightMoveDuration: 200
highlightMoveVelocity: -1
highlightResizeDuration: 0
// Only x,y are animated, and that is deliberate.
// There used to be add/remove transitions
// fading `opacity` and `scale` on the delegate
// root, and they corrupted the list: the model
// is re-diffed on EVERY keystroke, so a row
// part-way through a fade often survives the
// next pass as a move rather than a
// remove and move animates x,y only, so the
// half-finished opacity was never restored.
// Rows accumulated stuck at 0.3, 0.6, 1.0
// depending on typing speed, which looked like
// the colours randomly fading.
//
// x,y are safe to animate because the view
// re-asserts them on every layout, so an
// interrupted slide self-corrects. opacity and
// scale have no such backstop. If a fade is
// ever wanted back, animate a child item, not
// the root the view recycles.
move: Transition {
NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo }
}
displaced: Transition {
NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo }
}
delegate: Item {
required property string appId
required property string kind
required property string name
required property string icon
required property string desc
required property string glyph
required property bool pinned
required property int index
width: launcherList.width
height: 56
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 12
spacing: 12
// Apps carry a themed icon; file and
// web rows fall back to a glyph in
// the same 32px slot so names line up.
Item {
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 32
Image {
anchors.fill: parent
visible: source != ""
sourceSize.width: 32
sourceSize.height: 32
source: icon
}
SIcon {
anchors.centerIn: parent
visible: icon === ""
text: glyph
font.pixelSize: 20
color: Theme.base0D
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
// Leave room for the pin badge.
width: launcherList.width - (pinned ? 92 : 68)
spacing: 1
SText {
width: parent.width
text: name
font.pixelSize: 14
font.weight: Font.Medium
elide: Text.ElideRight
}
SText {
width: parent.width
visible: text !== ""
text: desc
font.pixelSize: 11
color: Theme.base04
elide: Text.ElideRight
}
}
}
SIcon {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: 12
visible: pinned
text: "pin"
font.pixelSize: 13
color: Theme.base04
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onEntered: launcherList.currentIndex = index
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) {
if (kind !== "app") return; // nothing to pin
const p = mapToItem(launcherPanel, mouse.x, mouse.y);
launcherPanel.menuX = p.x;
launcherPanel.menuY = p.y;
launcherPanel.menuAppId = appId;
} else {
launcherPanel.activate(launcherPanel.entries[index]);
}
}
}
}
}
}
// Category grid: the browse view when nothing is
// typed and no category is active.
Rectangle {
width: parent.width
height: catGrid.height + 2 * Theme.cardPad
radius: Theme.radiusCard
color: Theme.cardBg
visible: searchInput.text === "" && launcherPanel.activeCat === ""
Grid {
id: catGrid
anchors.top: parent.top
anchors.topMargin: Theme.cardPad
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 2 * Theme.cardPad
columns: 2
columnSpacing: 8
rowSpacing: 8
Repeater {
model: launcherPanel.catDefs
HoverRow {
required property var modelData
width: (catGrid.width - catGrid.columnSpacing) / 2
height: 48
onClicked: { launcherPanel.activeCat = modelData.key; launcherPanel.refilter(); }
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 12
spacing: 10
SIcon {
anchors.verticalCenter: parent.verticalCenter
text: modelData.icon
font.pixelSize: 20
color: Theme.base0D
}
Column {
anchors.verticalCenter: parent.verticalCenter
SText {
text: modelData.label
font.weight: Font.Medium
}
SText {
text: launcherPanel.catCount(modelData.key) + " apps"
font.pixelSize: 10
color: Theme.base04
}
}
}
}
}
}
}
Rectangle {
width: parent.width
height: 44
radius: Theme.radiusCard
color: Theme.cardBg
SIcon {
id: searchIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "search"
color: Theme.base04
font.pixelSize: 18
}
TextInput {
id: searchInput
anchors.left: searchIcon.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 14
clip: true
onTextChanged: launcherPanel.refilter()
// Escape backs out of the context menu first,
// then closes the launcher.
Keys.onEscapePressed: {
if (launcherPanel.menuAppId !== "") launcherPanel.menuAppId = "";
else 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.submit()
Keys.onEnterPressed: launcherPanel.submit()
}
SText {
anchors.left: searchIcon.right
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
visible: searchInput.text === ""
text: launcherPanel.activeCat === "" ? "Search apps, files, the web" : "Search all apps"
color: Theme.base03
font.pixelSize: 14
}
}
}
// Right-click menu. The backdrop swallows the next click
// anywhere in the panel so the menu dismisses without
// also launching whatever is underneath it.
MouseArea {
anchors.fill: parent
visible: launcherPanel.menuAppId !== ""
acceptedButtons: Qt.LeftButton | Qt.RightButton
z: 9
onClicked: launcherPanel.menuAppId = ""
}
Rectangle {
id: ctxMenu
visible: launcherPanel.menuAppId !== ""
z: 10
width: 156
height: 34
radius: Theme.radiusSmall
color: Theme.base02
x: Math.max(0, Math.min(parent.width - width, launcherPanel.menuX))
y: Math.max(0, Math.min(parent.height - height, launcherPanel.menuY))
readonly property bool pinned: launcherPanel.isPinned(launcherPanel.menuAppId)
HoverRow {
anchors.fill: parent
onClicked: launcherPanel.togglePin(launcherPanel.menuAppId)
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 10
spacing: 8
SIcon {
anchors.verticalCenter: parent.verticalCenter
text: ctxMenu.pinned ? "pin-off" : "pin"
font.pixelSize: 14
color: Theme.base0D
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: ctxMenu.pinned ? "Unpin" : "Pin to top"
}
}
}
}
}
}
// Click-outside dismissal for every panel the launcher,
// session menu AND the bar dropdowns. Clicking into another
// window (or anywhere outside the bar) clears the grab and
// closes whatever is open. Suspended while screenshotPinned so
// slurp can grab input without dismissing the menu.
HyprlandFocusGrab {
active: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned
windows: [bar]
onCleared: {
if (bar.screenshotPinned) return;
launcherPanel.open = false;
bar.closeAllDropdowns();
}
}
property var activeDropdown: null
// Set by the screenshot keybind (via IPC) to hold menus open
// while a region screenshot runs. Watchdog unpins if the
// bracketing unpin call is ever missed.
property bool screenshotPinned: false
Timer {
id: _pinWatchdog
interval: 30000
onTriggered: bar.screenshotPinned = false
}
function setScreenshotPin(v) {
screenshotPinned = v;
if (v) _pinWatchdog.restart(); else _pinWatchdog.stop();
}
function closeAllDropdowns() {
if (activeDropdown && activeDropdown.visible) {
activeDropdown.animateClose();
}
}
// Preview image for a notification: its image hint, else a
// file-path appIcon (e.g. hyprshot's saved shot). "" = none.
function notifPreviewSource(notif) {
if (!notif) return "";
if (notif.image && notif.image !== "") return notif.image;
let a = notif.appIcon || "";
if (a.startsWith("file://")) return a;
if (a.startsWith("/")) return "file://" + a;
return "";
}
function toggleDropdown(dd, setupFn) {
if (dd.visible && !dd.closing) {
dd.animateClose();
} else {
if (setupFn) setupFn();
// Opening from fully closed: seed the chrome as a
// small stub on the widget so the panel grows out of
// it (reviving mid-close morphs back instead).
// width, not height: the growth axis is horizontal now.
// chrome.height is tH, which is never 0, so testing it
// meant this never fired and the panel never seeded.
if (!activeDropdown && chrome.width < 0.5) {
chrome.seedFromButton(dd);
}
// Retarget the chrome before closing the previous
// dropdown so it morphs instead of dipping closed.
const prev = activeDropdown;
activeDropdown = dd;
if (prev && prev !== dd && prev.visible) {
prev.animateClose();
}
if (dd.closing) {
dd.revive();
} else {
dd.visible = true;
}
}
}
// SystemClock ticks on the minute boundary instead of a 1 Hz
// Timer; the calendar reads clockText.now too.
SystemClock {
id: sysClock
precision: SystemClock.Minutes
}
// Top cluster: logo, clock, workspaces the three groups
// that belong to "this machine, right now", split by hairlines.
// The server glance is NOT here: it anchors above the bottom
// cluster instead, so adding a workspace grows this column
// downward into empty space rather than shoving the stats.
Column {
id: barTop
anchors.top: parent.top
anchors.topMargin: 12
anchors.horizontalCenter: barBgRect.horizontalCenter
spacing: 12
// NixOS snowflake, flattened to the theme accent with the
// same hidden-Image + ColorOverlay trick the tray uses
// (no icon font ships a NixOS logo).
Item {
width: Theme.barWidth
height: 24
Image {
id: nixLogoImg
anchors.centerIn: parent
width: 22
height: 22
source: "file://" + Commands.nixLogo
sourceSize.width: 22
sourceSize.height: 22
smooth: true
visible: false
}
ColorOverlay {
anchors.fill: nixLogoImg
source: nixLogoImg
color: Theme.base0D
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: bar.toggleLauncher()
}
}
BarSep {}
// Clock HH over mm; "HH:mm" does not fit a 46px column.
Item {
id: clockText
width: Theme.barWidth
height: clockCol.height
property date now: sysClock.date
Column {
id: clockCol
anchors.horizontalCenter: parent.horizontalCenter
spacing: 0
SText {
anchors.horizontalCenter: parent.horizontalCenter
text: clockText.now.toLocaleTimeString(Qt.locale(), "HH")
font.pixelSize: 15
font.weight: Font.Medium
}
SText {
anchors.horizontalCenter: parent.horizontalCenter
text: clockText.now.toLocaleTimeString(Qt.locale(), "mm")
font.pixelSize: 15
font.weight: Font.Medium
color: Theme.base04
}
}
// Anchors to the clock like every other dropdown
// anchors to its widget; the clamp in BarDropdown
// pulls it back on screen when it overhangs.
function openCalendar() {
bar.toggleDropdown(calPopup, function() {
let pos = clockText.mapToItem(bar.contentItem, 0, clockText.height / 2);
calPopup.dropdownY = pos.y;
calPopup.resetView();
});
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: clockText.openCalendar()
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== calPopup) clockText.openCalendar();
}
}
}
}
BarSep {}
// Workspace dots: accent pill for the focused workspace,
// dim dots otherwise. Stacked vertically now, so the
// focused pill grows along HEIGHT rather than width.
Column {
spacing: 4
Repeater {
model: Hyprland.workspaces
Item {
id: wsItem
required property var modelData
visible: modelData.id > 0
width: Theme.barWidth
height: visible ? dot.height + 6 : 0
Rectangle {
id: dot
anchors.centerIn: parent
width: 6
height: wsItem.modelData.focused ? 18 : 6
radius: 3
color: wsItem.modelData.focused ? Theme.base0D
: wsMa.containsMouse ? Theme.base04 : Theme.base03
Behavior on height {
NumberAnimation { duration: 200; easing.type: Easing.OutExpo }
}
Behavior on color { ColorAnimation { duration: Theme.animFade } }
}
MouseArea {
id: wsMa
anchors.fill: parent
hoverEnabled: true
onClicked: wsItem.modelData.activate()
}
}
}
}
}
// Mid cluster: the server glance, pinned just above the
// bottom cluster. Its own anchor (not a barTop child) is the
// whole point: workspace count then changes nothing here.
//
// Server monitor: one persistent SSH streaming `top -b`
// from the mediaserver (2s ticks, parsed line by line
// no per-poll reconnects) plus a streaming ping to the
// router. Glance = CPU/RAM numbers + ping ms; click
// opens the btop-style dropdown.
Item {
id: srvWidget
width: Theme.barWidth
height: srvGlanceCol.height
anchors.horizontalCenter: barBgRect.horizontalCenter
anchors.bottom: barBottom.top
anchors.bottomMargin: 20
// Fades out while the OSD takes this slot.
opacity: osdItem.shown ? 0 : 1
Behavior on opacity {
NumberAnimation { duration: Theme.animContent }
}
property bool connected: false
property real cpu: 0
property real memUsed: 0 // GiB
property real memTotal: 0
property real pingMs: -1
property string uptime: ""
property string load: ""
property var procs: []
property int tempC: -1
property real rxBps: 0
property real txBps: 0
function fmtRate(bps) {
const b = bps * 8;
if (b >= 1e9) return (b / 1e9).toFixed(2) + " Gb/s";
if (b >= 1e6) return (b / 1e6).toFixed(1) + " Mb/s";
if (b >= 1e3) return Math.round(b / 1e3) + " Kb/s";
return Math.round(b) + " b/s";
}
// Split variants for the bar glance: number and unit
// live on separate lines, so both stay narrow.
function rateVal(bps) {
const b = bps * 8;
if (b >= 1e9) return (b / 1e9).toFixed(1);
if (b >= 1e7) return Math.round(b / 1e6).toString();
if (b >= 1e6) return (b / 1e6).toFixed(1);
return Math.round(b / 1e3).toString();
}
function rateUnit(bps) {
const b = bps * 8;
if (b >= 1e9) return "gb/s";
if (b >= 1e6) return "mb/s";
return "kb/s";
}
property var _procsPending: []
property bool _collecting: false
function _parseLine(line) {
if (line.indexOf("@STAT ") === 0) {
const t = line.match(/temp=(\d+)/);
if (t) tempC = parseInt(t[1]);
const rx = line.match(/rxbps=(-?\d+)/);
if (rx) rxBps = Math.max(0, parseInt(rx[1]));
const tx = line.match(/txbps=(-?\d+)/);
if (tx) txBps = Math.max(0, parseInt(tx[1]));
return;
}
if (line.indexOf("top - ") === 0) {
if (_collecting) procs = _procsPending;
_collecting = false;
const up = line.match(/up\s+(.*?),\s*\d+\s+users?,/);
if (up) uptime = up[1].replace(/\s+/g, " ");
const la = line.match(/load average:\s*(.*)$/);
if (la) load = la[1];
return;
}
if (line.indexOf("%Cpu(s)") === 0) {
const m = line.match(/([\d.]+)\s+id/);
if (m) { cpu = Math.max(0, 100 - parseFloat(m[1])); connected = true; }
return;
}
const mem = line.match(/^(\S+) Mem\s*:\s*([\d.]+)\s+total,\s*[\d.]+\s+free,\s*([\d.]+)\s+used/);
if (mem) {
const div = mem[1] === "KiB" ? 1048576 : mem[1] === "MiB" ? 1024 : 1;
memTotal = parseFloat(mem[2]) / div;
memUsed = parseFloat(mem[3]) / div;
return;
}
if (/^\s*PID\s/.test(line)) { _collecting = true; _procsPending = []; return; }
if (_collecting) {
const f = line.trim().split(/\s+/);
if (f.length >= 12) {
_procsPending.push({ pid: f[0], cpu: parseFloat(f[8]), mem: parseFloat(f[9]), name: f.slice(11).join(" ") });
// top sorts by %CPU; first 10 rows are the story
if (_procsPending.length >= 10) { procs = _procsPending; _collecting = false; }
} else {
procs = _procsPending; _collecting = false;
}
}
}
Process {
id: srvTop
command: [Commands.ssh, "-o", "BatchMode=yes", "-o", "ConnectTimeout=3",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2",
"fred@10.0.0.1", "qs-stats"]
running: true
stdout: SplitParser {
onRead: data => srvWidget._parseLine(data)
}
onRunningChanged: if (!running) { srvWidget.connected = false; srvRestart.start(); }
}
Timer { id: srvRestart; interval: 10000; onTriggered: srvTop.running = true }
// Ping is deliberately separate from the SSH stream:
// ping up + ssh down = box alive but wedged (seen it).
Process {
id: srvPing
command: [Commands.ping, "-n", "-i", "2", "10.0.0.1"]
running: true
stdout: SplitParser {
onRead: data => {
const m = data.match(/time=([\d.]+)\s*ms/);
if (m) { srvWidget.pingMs = parseFloat(m[1]); pingStale.restart(); }
}
}
onRunningChanged: if (!running) pingRestart.start()
}
Timer { id: pingRestart; interval: 10000; onTriggered: srvPing.running = true }
// No echo reply for 3 intervals -> show dashes, not a stale number.
Timer { id: pingStale; interval: 6000; onTriggered: srvWidget.pingMs = -1 }
function openSrvDropdown() {
bar.toggleDropdown(srvDropdown, function() {
let pos = srvWidget.mapToItem(bar.contentItem, 0, srvWidget.height / 2);
srvDropdown.dropdownY = pos.y;
});
}
// Stat stack, clock-sized values over faded captions.
Column {
id: srvGlanceCol
anchors.centerIn: parent
spacing: 5
GlanceStat {
value: srvWidget.connected ? Math.round(srvWidget.cpu) + "%" : "--"
caption: "cpu"
valueColor: srvWidget.connected ? Theme.base0D : Theme.base03
}
GlanceStat {
value: srvWidget.connected && srvWidget.memTotal > 0
? Math.round(100 * srvWidget.memUsed / srvWidget.memTotal) + "%" : "--"
caption: "ram"
valueColor: srvWidget.connected ? Theme.info : Theme.base03
}
GlanceStat {
value: srvWidget.connected && srvWidget.tempC >= 0 ? srvWidget.tempC + "°" : "--"
caption: "temp"
valueColor: !srvWidget.connected || srvWidget.tempC < 0 ? Theme.base03
: srvWidget.tempC >= 85 ? Theme.err
: srvWidget.tempC >= 70 ? Theme.warn : Theme.base05
}
GlanceStat {
value: srvWidget.connected ? srvWidget.rateVal(srvWidget.rxBps) : "--"
caption: " " + srvWidget.rateUnit(srvWidget.rxBps)
valueColor: srvWidget.connected ? Theme.ok : Theme.base03
}
GlanceStat {
value: srvWidget.connected ? srvWidget.rateVal(srvWidget.txBps) : "--"
caption: " " + srvWidget.rateUnit(srvWidget.txBps)
valueColor: srvWidget.connected ? Theme.alt : Theme.base03
}
GlanceStat {
value: srvWidget.pingMs < 0 ? "--"
: srvWidget.pingMs < 10 ? srvWidget.pingMs.toFixed(1)
: Math.round(srvWidget.pingMs).toString()
caption: "ms"
valueColor: srvWidget.pingMs < 0 ? Theme.base03 : Theme.base04
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: srvWidget.openSrvDropdown()
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== srvDropdown) srvWidget.openSrvDropdown();
}
}
}
}
// Bottom cluster: status icons, tray, power button. Anchored
// to the bottom edge; the server glance sits just above it and
// the OSD borrows that same slot while it is on screen.
Column {
id: barBottom
anchors.bottom: parent.bottom
anchors.bottomMargin: 12
anchors.horizontalCenter: barBgRect.horizontalCenter
spacing: 16
// Volume
Item {
id: volWidget
width: Theme.barWidth
height: 26
property PwNode sink: Pipewire.defaultAudioSink
PwObjectTracker {
objects: [volWidget.sink]
}
property int vol: sink && sink.audio ? Math.round(sink.audio.volume * 100) : 0
property bool muted: sink && sink.audio ? sink.audio.muted : false
// caelestia's getVolumeIcon shape, in Lucide's names.
property string volIcon: muted ? "volume-x"
: vol >= 50 ? "volume-2"
: vol > 0 ? "volume-1"
: "volume"
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 int netStrength: 0
// Signal strength picks the arc glyph. Lucide draws four
// wifi levels where Material Symbols drew five, so the
// bottom two of caelestia's buckets collapse into one.
readonly property string netIcon:
netState !== "connected"
? (netType === "ethernet" ? "unplug" : "wifi-off")
: netType === "ethernet" ? "cable"
: netStrength >= 80 ? "wifi"
: netStrength >= 60 ? "wifi-high"
: netStrength >= 40 ? "wifi-low"
: "wifi-zero"
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;
// netIcon is a binding now; wifi needs the
// active AP's signal to pick its glyph.
if (netWidget.netState === "connected" && netWidget.netType === "wifi")
wifiSignalProc.running = true;
}
}
}
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 = [];
}
}
}
// No --rescan, so this reads NetworkManager's cached
// scan cheap enough to run on every net event.
Process {
id: wifiSignalProc
command: [Commands.nmcli, "-t", "-f", "IN-USE,SIGNAL", "device", "wifi"]
stdout: SplitParser {
onRead: data => {
let f = data.split(":");
if (f[0] === "*") netWidget.netStrength = parseInt(f[1]) || 0;
}
}
}
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"
// Lucide draws four battery levels and a single charging
// glyph, so caelestia's 7-step scale and its graded
// charging variants don't survive the swap. Low-battery
// warning is carried by colour below.
property string batteryIcon: charging ? "battery-charging"
: batteryLevel >= 75 ? "battery-full"
: batteryLevel >= 50 ? "battery-medium"
: batteryLevel >= 20 ? "battery-low"
: "battery"
// 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.err
: batteryWidget.batteryLevel <= 30 ? Theme.warn
: Theme.base05
font.pixelSize: 18
}
SText {
id: batteryText
anchors.horizontalCenter: parent.horizontalCenter
text: batteryWidget.batteryLevel
color: batteryWidget.batteryLevel <= 15 ? Theme.err
: batteryWidget.batteryLevel <= 30 ? Theme.warn
: 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: "moon"
font.pixelSize: 18
}
function openQsDropdown() {
bar.toggleDropdown(qsDropdown, function() {
let pos = qsWidget.mapToItem(bar.contentItem, 0, qsWidget.height / 2);
qsDropdown.dropdownY = pos.y;
});
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: qsWidget.openQsDropdown()
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== qsDropdown) qsWidget.openQsDropdown();
}
}
}
}
''}
// Group separator: status icons / tray / power read
// as three clusters instead of one clump.
BarSep {}
// Tray icons stacked, like everything else in the column
Column {
id: trayArea
spacing: 8
width: Theme.barWidth
// NO vertical anchor here: a Column refuses to position
// ANY child if one of them sets top/bottom/verticalCenter
// /fill/centerIn, and silently piles them all at y=0.
Repeater {
model: SystemTray.items
Item {
required property var modelData
width: Theme.barWidth
height: 24
Image {
id: trayIcon
anchors.centerIn: parent
width: 16
height: 16
source: modelData.icon
sourceSize.width: 16
sourceSize.height: 16
smooth: true
mipmap: true
visible: false
}
ColorOverlay {
anchors.fill: trayIcon
source: trayIcon
color: Theme.base05
}
// The icon's own menu cache: assigned from the
// moment the icon exists, so the DBus fetch
// (slow for Steam) happens at startup, not
// mid-animation. contextMenu just points at
// this switching tray icons never reloads.
QsMenuOpener {
id: itemOpener
menu: modelData.menu
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
onEntered: {
if (!modelData.hasMenu) return;
if (!bar.activeDropdown) return;
if (bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData) return;
let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
if (bar.activeDropdown === contextMenu) {
// Same dropdown, just switch content
contextMenu.dropdownY = pos.y;
contextMenu.trayItem = modelData;
contextMenu.activeOpener = itemOpener;
} else {
contextMenu.openFor(modelData, pos.y, itemOpener);
}
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (event) => {
if (!modelData.hasMenu) {
modelData.activate();
} else if (bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData) {
// Clicking the open menu's own icon still closes it
contextMenu.animateClose();
} else {
let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
contextMenu.openFor(modelData, pos.y, itemOpener);
}
}
}
}
}
}
// Group separator: status icons / tray / power read
// as three clusters instead of one clump.
BarSep {}
// Power menu opener bottom-left corner. Opens the same
// session menu as Super+L, which now unfolds from here.
Item {
id: powerWidget
width: Theme.barWidth
height: 26
SIcon {
anchors.centerIn: parent
text: "power"
color: powerMa.containsMouse || sessionMenu.open ? Theme.err : 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: "log-out", danger: false, act: "logout" },
{ icon: "rotate-ccw", danger: true, act: "reboot" },
{ icon: "power", danger: true, act: "poweroff" }
]
function activate(act) {
animateClose();
if (act === "lock") bar.shellRoot.lock();
else if (act === "logout") Hyprland.dispatch("hl.dsp.exit()");
else if (act === "reboot") Quickshell.execDetached([Commands.systemctl, "reboot"]);
else Quickshell.execDetached([Commands.systemctl, "poweroff"]);
}
focus: open
onOpenChanged: if (open) { selIdx = 0; forceActiveFocus(); }
Keys.onEscapePressed: bar.closeAllDropdowns()
Keys.onUpPressed: selIdx = (selIdx + actions.length - 1) % actions.length
Keys.onDownPressed: selIdx = (selIdx + 1) % actions.length
Keys.onTabPressed: selIdx = (selIdx + 1) % actions.length
Keys.onReturnPressed: activate(actions[selIdx].act)
Keys.onEnterPressed: activate(actions[selIdx].act)
// Card backing, matching the other dropdowns. The grow /
// clip reveal is BarDropdown's job now.
Rectangle {
id: sessionCard
anchors.centerIn: parent
width: 48
height: sessionCol.height + 8
radius: Theme.radiusCard
color: Theme.cardBg
// Sliding selection pill same tech as the power
// profile selector; glides between the buttons.
Rectangle {
width: 40
height: 40
radius: Theme.radiusCard
color: Theme.base02
border.width: 1
border.color: Theme.base03
x: sessionCol.x
y: sessionCol.y + sessionMenu.selIdx * 44
Behavior on y {
NumberAnimation { duration: 250; easing.type: Easing.OutExpo }
}
}
Column {
id: sessionCol
anchors.centerIn: parent
spacing: 4
Repeater {
model: sessionMenu.actions
Item {
id: sessBtn
required property var modelData
required property int index
width: 40
height: 40
SIcon {
anchors.centerIn: parent
text: sessBtn.modelData.icon
// All four buttons share the logout
// setup: base05, FILL on selection.
color: Theme.base05
font.pixelSize: 20
font.weight: 600
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: sessionMenu.selIdx = sessBtn.index
onClicked: sessionMenu.activate(sessBtn.modelData.act)
}
}
}
}
}
}
// Volume dropdown
// Server monitor dropdown the btop-at-a-distance view.
BarDropdown {
id: srvDropdown
fullWidth: srvDropdownCol.width + 2 * Theme.panelGap
fullHeight: srvDropdownCol.height + 2 * Theme.panelGap
Column {
id: srvDropdownCol
anchors.centerIn: parent
width: 340
spacing: 8
Card {
width: parent.width
Row {
spacing: 6
SIcon {
anchors.verticalCenter: parent.verticalCenter
text: "server"
color: srvWidget.connected ? Theme.ok : Theme.err
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: "FredOS-Mediaserver"
font.weight: Font.Medium
}
}
SText {
width: parent.width
text: srvWidget.connected
? "up " + srvWidget.uptime + " load " + srvWidget.load
: "unreachable"
font.pixelSize: 12
color: srvWidget.connected ? Theme.base04 : Theme.err
elide: Text.ElideRight
}
Row {
width: parent.width
spacing: 10
SText { width: 42; text: "cpu"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter }
MeterBar {
width: parent.width - 42 - 76 - 20
anchors.verticalCenter: parent.verticalCenter
value: srvWidget.cpu / 100
fillColor: srvWidget.cpu >= 90 ? Theme.err : Theme.base0D
}
SText {
width: 76
text: srvWidget.cpu.toFixed(1) + "%"
font.weight: Font.Medium
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
width: parent.width
spacing: 10
SText { width: 42; text: "ram"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter }
MeterBar {
width: parent.width - 42 - 76 - 20
anchors.verticalCenter: parent.verticalCenter
value: srvWidget.memTotal > 0 ? srvWidget.memUsed / srvWidget.memTotal : 0
fillColor: Theme.info
}
SText {
width: 76
text: srvWidget.memUsed.toFixed(1) + "/" + Math.round(srvWidget.memTotal) + "G"
font.weight: Font.Medium
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
width: parent.width
spacing: 10
SText { width: 42; text: "temp"; color: Theme.base04 }
SText {
text: srvWidget.tempC < 0 ? "--" : srvWidget.tempC + "°C"
font.weight: Font.Medium
color: srvWidget.tempC < 0 ? Theme.base03
: srvWidget.tempC >= 85 ? Theme.err
: srvWidget.tempC >= 70 ? Theme.warn : Theme.base05
}
}
Row {
width: parent.width
spacing: 10
SText { width: 42; text: "wan"; color: Theme.base04 }
Row {
spacing: 14
Row {
spacing: 4
SIcon { text: "arrow-down"; font.pixelSize: 14; color: Theme.ok; anchors.verticalCenter: parent.verticalCenter }
SText {
text: srvWidget.fmtRate(srvWidget.rxBps)
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
spacing: 4
SIcon { text: "arrow-up"; font.pixelSize: 14; color: Theme.alt; anchors.verticalCenter: parent.verticalCenter }
SText {
text: srvWidget.fmtRate(srvWidget.txBps)
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
Row {
width: parent.width
spacing: 10
SText { width: 42; text: "ping"; color: Theme.base04 }
SText {
text: srvWidget.pingMs < 0 ? "--" : srvWidget.pingMs.toFixed(1) + " ms"
font.weight: Font.Medium
color: srvWidget.pingMs < 0 ? Theme.base03 : Theme.base05
}
}
}
Card {
width: parent.width
Row {
spacing: 6
SIcon { anchors.verticalCenter: parent.verticalCenter; text: "activity" }
SText {
anchors.verticalCenter: parent.verticalCenter
text: "Processes"
font.weight: Font.Medium
}
}
Row {
width: parent.width
SText { width: parent.width - 112; text: "name"; font.pixelSize: 11; color: Theme.base03 }
SText { width: 56; text: "cpu%"; font.pixelSize: 11; color: Theme.base03; horizontalAlignment: Text.AlignRight }
SText { width: 56; text: "mem%"; font.pixelSize: 11; color: Theme.base03; horizontalAlignment: Text.AlignRight }
}
Column {
width: parent.width
spacing: 3
Repeater {
model: srvWidget.procs
Row {
required property var modelData
width: parent.width
height: 21
SText {
width: parent.width - 112
text: modelData.name
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
SText {
width: 56
text: modelData.cpu.toFixed(1)
horizontalAlignment: Text.AlignRight
color: modelData.cpu >= 100 ? Theme.err
: modelData.cpu >= 25 ? Theme.warn : Theme.base05
anchors.verticalCenter: parent.verticalCenter
}
SText {
width: 56
text: modelData.mem.toFixed(1)
horizontalAlignment: Text.AlignRight
color: Theme.base04
anchors.verticalCenter: parent.verticalCenter
}
}
}
SText {
visible: srvWidget.procs.length === 0
text: "no data"
color: Theme.base03
}
}
}
}
}
BarDropdown {
id: volDropdown
fullWidth: volDropdownCol.width + 2 * Theme.panelGap
fullHeight: volDropdownCol.height + 2 * Theme.panelGap
Column {
id: volDropdownCol
anchors.centerIn: parent
width: 268
spacing: 8
// Master volume card
Card {
width: parent.width
Row {
spacing: 6
VolIcon { anchors.verticalCenter: parent.verticalCenter; audioNode: volWidget.sink ? volWidget.sink.audio : null }
SText {
anchors.verticalCenter: parent.verticalCenter
text: "Master"
font.weight: Font.Medium
}
}
Row {
width: parent.width
spacing: 8
PillSlider {
width: parent.width - masterVolLabel.width - 8
anchors.verticalCenter: parent.verticalCenter
value: volWidget.sink && volWidget.sink.audio ? Math.min(1, volWidget.sink.audio.volume) : 0
fillColor: volWidget.muted ? Theme.base03 : Theme.base0D
onMoved: (v) => { if (volWidget.sink && volWidget.sink.audio) volWidget.sink.audio.volume = v; }
}
SText {
id: masterVolLabel
width: 36
text: volWidget.vol + "%"
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
// Mute button
HoverRow {
width: parent.width
height: 28
onClicked: {
if (volWidget.sink && volWidget.sink.audio)
volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
}
Row {
anchors.centerIn: parent
spacing: 6
SIcon {
anchors.verticalCenter: parent.verticalCenter
text: volWidget.muted ? "volume-x" : "volume-2"
font.pixelSize: 15
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: volWidget.muted ? "Unmute" : "Mute"
font.pixelSize: 12
}
}
}
}
${lib.optionalString (!isMacbook) ''
// Nova Pro noise cancelling writes straight to the
// base station's HID interface via the nova-anc
// script (no daemon). Card hides when the dongle or
// headset is gone.
Card {
id: ancCard
visible: ancMode !== ""
width: parent.width
property string ancMode: ""
function setMode(key) {
if (key === ancMode) return;
ancMode = key; // optimistic; next poll corrects if the device disagrees
ancSet.mode = key;
ancSet.running = true;
}
Process {
id: ancSet
property string mode: "off"
command: [Commands.novaAnc, mode]
}
Process {
id: ancStatus
command: [Commands.novaAnc]
stdout: StdioCollector {
onStreamFinished: {
const m = text.trim();
ancCard.ancMode = (m === "off" || m === "transparent" || m === "on") ? m : "";
}
}
}
// Poll only while the dropdown is up; keeps ticking
// with the card hidden so a headset appearing
// mid-open reveals it.
Timer {
interval: 3000
repeat: true
triggeredOnStart: true
running: volDropdown.visible
onTriggered: ancStatus.running = true
}
Row {
spacing: 6
SIcon { anchors.verticalCenter: parent.verticalCenter; text: "headphones" }
SText {
anchors.verticalCenter: parent.verticalCenter
text: "Noise Cancelling"
font.weight: Font.Medium
}
}
Row {
width: parent.width
spacing: 6
Repeater {
model: [
{ key: "off", label: "Off" },
{ key: "transparent", label: "Transparent" },
{ key: "on", label: "ANC" }
]
Rectangle {
required property var modelData
property bool active: ancCard.ancMode === modelData.key
width: (parent.width - 12) / 3
height: 26
radius: 6
color: active ? Theme.base0D : "transparent"
border.color: active ? Theme.base0D : Theme.base03
border.width: 1
SText {
anchors.centerIn: parent
text: modelData.label
font.pixelSize: 11
color: active ? Theme.base00 : Theme.base05
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: ancCard.setMode(modelData.key)
}
}
}
}
}
''}
// Applications card
Card {
visible: appStreamsCol.childrenRect.height > 0
width: parent.width
Row {
spacing: 6
SIcon { anchors.verticalCenter: parent.verticalCenter; text: "audio-waveform" }
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.base0D
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.netIcon
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 = "";
bar.closeAllDropdowns();
netRefreshDelay.start();
}
SText {
anchors.centerIn: parent
text: "Disconnect"
color: Theme.err
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 {
// Lucide has no padlocked wifi arc,
// so security is shown by the lock
// glyph on the row's right instead.
text: {
let s = modelData.signal;
if (s >= 80) return "wifi";
if (s >= 60) return "wifi-high";
if (s >= 40) return "wifi-low";
return "wifi-zero";
}
color: modelData.active ? Theme.ok : Theme.base04
font.pixelSize: 16
anchors.verticalCenter: parent.verticalCenter
}
SText {
text: modelData.ssid
color: modelData.active ? Theme.ok : 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: "leaf", tip: "Saver" },
{ name: "balanced", profile: PowerProfile.Balanced, label: "scale", tip: "Balanced" },
{ name: "performance", profile: PowerProfile.Performance, label: "gauge", 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
// Glyph tracks the level instead of sitting on
// one static step.
SIcon { width: 18; anchors.verticalCenter: parent.verticalCenter; text: brightCard.screenB >= 0.66 ? "sun" : brightCard.screenB >= 0.33 ? "sun-medium" : "sun-dim"; 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 Lucide. 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 "sun";
if (code <= 2) return "cloud-sun";
if (code === 3) return "cloud";
if (code <= 48) return "cloud-fog";
if (code <= 67) return "cloud-rain";
if (code <= 77) return "cloud-snow";
if (code <= 82) return "cloud-rain";
if (code <= 86) return "cloud-snow";
return "cloud-lightning";
}
// --- 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;
// Apps are inconsistent about which property carries their
// name Spotify doesn't fill application.name the way
// browsers do so try every property that plausibly
// holds it, against both MPRIS names. Candidates shorter
// than 3 chars are skipped: substring matching on those
// matches almost anything.
let want = [];
let add = s => {
s = (s || "").toLowerCase();
if (s.length >= 3) want.push(s);
};
add(player.identity);
add(player.desktopEntry);
let ns = Pipewire.nodes.values;
for (let i = 0; i < ns.length; i++) {
let n = ns[i];
if (!n.isStream || !n.audio) continue;
let p = n.properties;
let cands = [
p["application.name"],
p["node.name"],
p["application.process.binary"]
];
for (let c = 0; c < cands.length; c++) {
let an = (cands[c] || "").toLowerCase();
if (an.length < 3) continue;
for (let w = 0; w < want.length; w++) {
if (an === want[w] || an.includes(want[w]) || want[w].includes(an))
return n;
}
}
}
return null;
}
// Live spectrum for the media cards' rings. cava runs only
// while this popup is open AND something is playing, so it
// isn't capturing audio in the background. levels is a
// barCount-long array of 0-100 ints, one frame per tick.
readonly property int barCount: 24
property var levels: []
Process {
id: cavaProc
running: calPopup.open && calPopup.players.length > 0
command: [Commands.cava, "-p", Commands.cavaConfig]
stdout: SplitParser {
onRead: data => {
let parts = data.split(";");
let out = [];
for (let i = 0; i < parts.length; i++) {
if (parts[i] === "") continue;
out.push(parseInt(parts[i]) || 0);
}
if (out.length > 0) calPopup.levels = out;
}
}
}
// Bars decay to flat when cava stops, so a paused card
// doesn't keep the last frame frozen around the disc.
onOpenChanged: if (!open) levels = []
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.base0D
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)
// xesam:url is the only field that says where
// the audio actually comes from; identity just
// names the browser.
readonly property string pageUrl: {
let md = modelData.metadata || ({});
return md["xesam:url"] || "";
}
property string faviconUrl: ""
function refreshFavicon() {
faviconUrl = "";
// Rewriting a live command truncates the
// read, so skip while one is in flight.
if (pageUrl !== "" && !faviconProc.running)
faviconProc.running = true;
}
onPageUrlChanged: refreshFavicon()
Component.onCompleted: refreshFavicon()
Process {
id: faviconProc
command: [Commands.faviconFetch, mediaCard.pageUrl]
stdout: StdioCollector {
onStreamFinished: mediaCard.faviconUrl = text.trim()
}
}
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
// Vinyl: cover art is the disc label,
// spinning while playback runs, ringed
// by cava's spectrum. Kept at 128 so
// the row's text column is unaffected.
Item {
id: vinyl
width: 128; height: 128
anchors.verticalCenter: parent.verticalCenter
readonly property real discSize: 86
readonly property real ringR: 47
// Same expression the play/pause button
// uses, so the disc can never disagree
// with the glyph next to it.
readonly property bool spinning:
mediaCard.modelData.playbackState === MprisPlaybackState.Playing
// Spectrum ring. Each bar sits with
// its base on the ring and grows
// outward, then is rotated about the
// disc centre into its slot.
Repeater {
model: calPopup.barCount
Rectangle {
id: eqBar
required property int index
// cava hears the whole output mix,
// not this stream, so a paused
// card must flatten its own ring
// otherwise every card dances to
// whatever else is playing.
readonly property real lvl:
(vinyl.spinning
&& index < calPopup.levels.length)
? calPopup.levels[index] / 100 : 0
width: 3
radius: 1.5
height: 3 + lvl * 13
color: Theme.base0D
opacity: 0.35 + lvl * 0.65
x: vinyl.width / 2 - width / 2
y: vinyl.height / 2 - vinyl.ringR - height
transform: Rotation {
origin.x: eqBar.width / 2
origin.y: vinyl.ringR + eqBar.height
angle: eqBar.index * (360 / calPopup.barCount)
}
Behavior on height {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
}
}
ClippingRectangle {
id: disc
anchors.centerIn: parent
width: vinyl.discSize
height: vinyl.discSize
radius: width / 2
color: Theme.base01
// Art-less fallback: a dark record with
// faint grooves. Deliberately low
// contrast this is a placeholder, it
// shouldn't out-shout the actual
// covers it stands in for. Grooves are
// eccentric by 1px so they visibly
// turn; concentric rings look static
// while spinning.
Item {
id: blankLabel
anchors.fill: parent
visible: albumArt.status !== Image.Ready
Repeater {
model: 4
Rectangle {
required property int index
anchors.centerIn: parent
anchors.horizontalCenterOffset: 3
width: vinyl.discSize - 8 - index * 11
height: width
radius: width / 2
color: "transparent"
border.width: 1
border.color: Theme.base03
opacity: 0.22
}
}
}
Image {
id: albumArt
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
asynchronous: true
source: mediaCard.modelData.trackArtUrl
}
// paused (not stopped) so resuming
// picks up at the current angle
// instead of snapping back to 0.
NumberAnimation on rotation {
from: 0; to: 360
duration: 8000
loops: Animation.Infinite
running: true
paused: !vinyl.spinning
}
}
// Label content sits outside the rotating
// disc so the favicon stays upright
// only the grooves turn. Doubles as the
// record's centre label, so no spindle
// hole is drawn over it.
Item {
anchors.centerIn: parent
width: 38; height: 38
visible: albumArt.status !== Image.Ready
Rectangle {
anchors.fill: parent
radius: width / 2
color: Theme.base02
}
// Circular crop: most favicons are a
// square plate on their own dark
// background, which fights the disc.
ClippingRectangle {
anchors.centerIn: parent
width: 24; height: 24
radius: width / 2
color: "transparent"
Image {
id: faviconImg
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
asynchronous: true
smooth: true
mipmap: true
visible: status === Image.Ready
source: mediaCard.faviconUrl
}
}
SIcon {
anchors.centerIn: parent
visible: faviconImg.status !== Image.Ready
text: sourceBadge.glyph !== "" ? sourceBadge.glyph : "music"
color: Theme.base04
font.pixelSize: 15
}
}
// Spindle hole, only over real artwork
// the blank label already owns the centre.
Rectangle {
anchors.centerIn: parent
visible: albumArt.status === Image.Ready
width: 14; height: 14
radius: 7
color: Theme.cardBg
border.width: 1
border.color: Theme.base03
}
// Source badge. MPRIS identity only ever
// names the browser, so the site comes
// from xesam:url in the raw metadata
// the one field that actually says where
// the audio is from.
Rectangle {
id: sourceBadge
readonly property string glyph: {
let md = mediaCard.modelData.metadata || ({});
let u = (md["xesam:url"] || "").toLowerCase();
let ident = (mediaCard.modelData.identity || "").toLowerCase();
if (u.indexOf("youtube.com") >= 0 || u.indexOf("youtu.be") >= 0) return "youtube";
if (u.indexOf("twitch.tv") >= 0) return "twitch";
// Jellyfin: our own server, no
// public hostname to match on.
if (u.indexOf(":8096") >= 0 || u.indexOf("jellyfin") >= 0) return "clapperboard";
if (ident.indexOf("mpv") >= 0 || ident.indexOf("vlc") >= 0) return "film";
if (u.indexOf("file://") === 0) return "music";
return "";
}
visible: sourceBadge.glyph !== ""
width: 22; height: 22
radius: 11
color: Theme.cardBg
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 6
SIcon {
anchors.centerIn: parent
text: sourceBadge.glyph
color: Theme.base05
font.pixelSize: 14
}
}
}
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-back", act: "prev" },
{ glyph: mediaCard.modelData.playbackState === MprisPlaybackState.Playing ? "pause" : "play", act: "toggle" },
{ glyph: "skip-forward", 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.base0D
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: "x"
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-2"
property real value: 0
property color fill: Theme.base0D
// Takes over the server glance's slot: fixed position, no
// dependence on how tall the workspace column has grown.
width: Theme.barWidth
height: 170
anchors.horizontalCenter: barBgRect.horizontalCenter
anchors.verticalCenter: srvWidget.verticalCenter
opacity: shown ? 1 : 0
visible: opacity > 0.01
Behavior on opacity {
NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
}
function show(ic, v, f) {
if (!armed || !isFocused) return;
icon = ic;
value = v;
fill = f;
shown = true;
osdTimer.restart();
}
Timer { id: osdTimer; interval: 1600; onTriggered: osdItem.shown = false }
Timer { id: osdArm; interval: 1500; running: true; onTriggered: osdItem.armed = true }
function showVolume() {
// The volume dropdown already shows the level live.
if (bar.activeDropdown === volDropdown) return;
show(volWidget.volIcon, volWidget.vol / 100,
volWidget.muted ? Theme.base03 : Theme.base0D);
}
Connections {
target: volWidget.sink && volWidget.sink.audio ? volWidget.sink.audio : null
// `volume` notifies through volumesChanged, not
// volumeChanged it is a view over the per-channel
// volumes vector.
function onVolumesChanged() { osdItem.showVolume(); }
function onMutedChanged() { osdItem.showVolume(); }
}
Connections {
target: bar.shellRoot
function onOsdBrightness(icon, value) {
osdItem.show(icon, value, Theme.base0D);
}
}
// Card body so the OSD reads as a surface, not floating
// marks on the bar. Scales in from slightly small.
Rectangle {
anchors.centerIn: parent
width: 36
height: osdCol.height + 2 * Theme.cardPad
radius: Theme.radiusSmall
color: Theme.cardBg
scale: osdItem.shown ? 1 : 0.85
Behavior on scale {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
Column {
id: osdCol
anchors.centerIn: parent
spacing: 6
SText {
anchors.horizontalCenter: parent.horizontalCenter
text: Math.round(Math.max(0, Math.min(1, osdItem.value)) * 100)
color: Theme.base04
font.pixelSize: 11
}
// Vertical track fill grows upward from the bottom.
// PillSlider is horizontal-only, so this is its own
// rather than a rotated instance.
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 12
height: 96
radius: 6
color: Theme.base02
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: Math.max(parent.width,
parent.height * Math.max(0, Math.min(1, osdItem.value)))
radius: parent.radius
color: osdItem.fill
Behavior on height {
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
}
}
SIcon {
anchors.horizontalCenter: parent.horizontalCenter
text: osdItem.icon
color: Theme.base05
font.pixelSize: 16
}
}
}
}
// Notification Toast (only on primary screen)
Item {
id: toastItem
visible: false
property var currentNotif: null
property bool toastOpen: false
readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "spotify_player", "spotify-player", "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: "x"
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(); }
}
}
}
}
}
}
'';
};
};
};
};
}