nixos/settings/quickshell.nix
rope efb49deabd quickshell: show greyed adjacent-month days in the calendar grid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:59:43 +01:00

7189 lines
377 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
'';
# Launcher weather. Neither of the other two answer paths can serve
# this: qs-ask runs with no tools at all, so Claude has no live data and
# correctly refuses, and Wikipedia answers "weather in kristiansand"
# with the city's founding date. open-meteo needs no key — geocode the
# place name, then read current conditions at those coordinates.
#
# An empty place (a bare "weather now") falls back to the same IP
# geolocation the calendar widget uses, so the common case needs no
# place name at all.
#
# Prints one finished sentence, or nothing when the place doesn't
# resolve or either call fails — which the QML reads as "no answer",
# exactly like the other two scripts.
wxFetchScript = pkgs.writeShellScript "wx-fetch" ''
place="$1"
jq="${pkgs.jq}/bin/jq"
if [ -n "$place" ]; then
geo=$(${pkgs.curl}/bin/curl -sf --max-time 5 -G \
--data-urlencode "name=$place" \
'https://geocoding-api.open-meteo.com/v1/search?count=1&language=en&format=json' 2>/dev/null)
[ -z "$geo" ] && exit 0
lat=$(printf '%s' "$geo" | $jq -r '.results[0].latitude // empty' 2>/dev/null)
lon=$(printf '%s' "$geo" | $jq -r '.results[0].longitude // empty' 2>/dev/null)
# Name it back the way open-meteo spells it ("Kristiansand, Norway"),
# which doubles as confirmation that it resolved what was meant.
where=$(printf '%s' "$geo" \
| $jq -r '[.results[0].name, .results[0].country] | map(select(. != null)) | join(", ")' 2>/dev/null)
else
loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true)
case "$loc" in
*,*) lat=''${loc%,*}; lon=''${loc#*,}; where="Here" ;;
esac
fi
[ -z "$lat" ] && exit 0
wx=$(${pkgs.curl}/bin/curl -sf --max-time 6 \
"https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto" 2>/dev/null)
[ -z "$wx" ] && exit 0
# One jq pass, four fields, split on whitespace every value is a
# number, so there is nothing here for word splitting to break.
cur=$(printf '%s' "$wx" \
| $jq -r '.current | "\(.weather_code) \(.temperature_2m) \(.apparent_temperature) \(.wind_speed_10m)"' 2>/dev/null)
case "$cur" in ""|*null*) exit 0 ;; esac
set -- $cur
code="$1"; temp="$2"; feels="$3"; wind="$4"
# WMO weather codes, grouped to the distinctions worth a word.
case "$code" in
0) desc="Clear" ;;
1) desc="Mainly clear" ;;
2) desc="Partly cloudy" ;;
3) desc="Overcast" ;;
45|48) desc="Fog" ;;
51|53|55) desc="Drizzle" ;;
56|57) desc="Freezing drizzle" ;;
61|63|65) desc="Rain" ;;
66|67) desc="Freezing rain" ;;
71|73|75) desc="Snow" ;;
77) desc="Snow grains" ;;
80|81|82) desc="Rain showers" ;;
85|86) desc="Snow showers" ;;
95) desc="Thunderstorm" ;;
96|99) desc="Thunderstorm with hail" ;;
*) desc="" ;;
esac
# "right now" is not filler: the launcher strips day words off the
# query so the place still geocodes, so a query asking about tomorrow
# gets today's numbers and has to be able to see that it did.
[ -n "$desc" ] && desc="$desc, "
printf '%s right now: %s%s°C (feels %s°C), wind %s km/h' \
"$where" "$desc" "$temp" "$feels" "$wind"
'';
# 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
Weird 1.0 Weird.qml
Archie 1.0 Archie.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: 20
}
// Lighter blur means less natural contrast, so the scrim
// is a left-weighted gradient instead of a flat 0.4 wash:
// dark under the text column, near-clear on the right.
Rectangle {
anchors.fill: parent
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "#b0000000" }
GradientStop { position: 0.55; color: "#59000000" }
GradientStop { position: 1.0; color: "#33000000" }
}
}
SystemClock {
id: lockClock
precision: SystemClock.Minutes
}
// Lower-left, not centred: clock/date/password stack left
// aligned against a 10% margin, sitting below the middle.
Column {
anchors.left: parent.left
anchors.leftMargin: Math.round(parent.width * 0.10)
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: Math.round(parent.height * 0.14)
spacing: 6
Text {
text: lockClock.date.toLocaleTimeString(Qt.locale(), "HH:mm")
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 104
font.weight: Font.DemiBold
}
Text {
text: lockClock.date.toLocaleDateString(Qt.locale(), "dddd, d MMMM")
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 18
}
Item { width: 1; height: 28 }
// Password pill: dots for typed chars; border tracks
// state (accent while checking, red on failure).
Rectangle {
width: 300
height: 46
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/Weird.qml" = {
onChange = qsRestart;
text = ''
import QtQuick
// Decorative only: a pixel inchworm loops around the screen edge
// and gets shoved about by the panels sliding out of the bar.
// Nothing here is load-bearing delete the file, its qmldir line
// and the one instantiation at the end of Bar.qml.
Item {
id: weird
anchors.fill: parent
// Pixel grid. Every worm segment snaps to it, which IS the
// pixel-art look there are no sprites involved.
readonly property int cell: 6
readonly property int segments: 11
// Inchworm gait
// A looper does not glide: it plants its tail, arches the
// middle into a hoop, throws the head forward, then drags
// the tail up to meet it. So the body is the rail interval
// [tailD, headD] and the two ends take turns moving the
// span oscillating between spanMin and spanMax IS the walk
// cycle, and the arch is just how tall the hoop has to be
// to absorb the slack.
readonly property real spanMin: 26
readonly property real spanMax: 66
readonly property real crawl: 2.2 // px per tick, whichever end is moving
readonly property real archMax: 26 // hoop height at full compression
// The hoop is only his middle. The face carries on ahead of
// it and the tail trails behind, both flat on the rail, so
// what you read is a body pumping between two planted ends
// rather than the whole animal rippling.
readonly property real headLead: 16
readonly property real tailDrag: 16
// The rail
// The worm rides the chrome cutout: the exact rounded rect
// the frame shader carves out of the shell, so he crawls the
// real screen edge, corners and all. Keep these in step with
// the ShaderEffect's `cutout` in Bar.qml.
readonly property real railL: Theme.barWidth
readonly property real railT: Theme.frameWidth
readonly property real railR: weird.width - Theme.frameWidth
readonly property real railB: weird.height - Theme.frameWidth
readonly property real rad: Math.min(Theme.radius, (railR - railL) / 2, (railB - railT) / 2)
readonly property real runX: railR - railL - 2 * rad // straight top/bottom run
readonly property real runY: railB - railT - 2 * rad // straight left/right run
readonly property real arc: Math.PI * rad / 2
readonly property real perim: 2 * (runX + runY) + 4 * arc
// Point at distance d clockwise along that outline, starting
// where the top-left corner lets go of the top edge: run,
// quarter arc, run, quarter arc, Each leg hands its end
// point to the next, so the path closes exactly.
function railPoint(d) {
d = ((d % perim) + perim) % perim;
let r = rad;
if (d < runX) return Qt.point(railL + r + d, railT);
d -= runX;
if (d < arc) { let a = -Math.PI / 2 + d / r; return Qt.point(railR - r + Math.cos(a) * r, railT + r + Math.sin(a) * r); }
d -= arc;
if (d < runY) return Qt.point(railR, railT + r + d);
d -= runY;
if (d < arc) { let a = d / r; return Qt.point(railR - r + Math.cos(a) * r, railB - r + Math.sin(a) * r); }
d -= arc;
if (d < runX) return Qt.point(railR - r - d, railB);
d -= runX;
if (d < arc) { let a = Math.PI / 2 + d / r; return Qt.point(railL + r + Math.cos(a) * r, railB - r + Math.sin(a) * r); }
d -= arc;
if (d < runY) return Qt.point(railL, railB - r - d);
d -= runY;
let a = Math.PI + d / r;
return Qt.point(railL + r + Math.cos(a) * r, railT + r + Math.sin(a) * r);
}
// Where a landing at x sits on the bottom run he always
// comes down onto the floor, so that is the only leg that
// ever needs solving backwards.
function railAtBottom(x) {
return runX + arc + runY + arc
+ Math.max(0, Math.min(runX, railR - rad - x));
}
// Inward unit normal at d which way "up off the surface"
// points. Taken from the tangent rather than cased per edge,
// so it swings round the corners for free: the path runs
// clockwise, so rotating the tangent by +90° in screen
// coordinates (y down) always aims into the screen.
function railNormal(d) {
let a = railPoint(d - 2), b = railPoint(d + 2);
let tx = b.x - a.x, ty = b.y - a.y;
let len = Math.hypot(tx, ty) || 1;
return Qt.point(-ty / len, tx / len);
}
// Worm
QtObject {
id: worm
property real backD: Math.random() * 4000 // rail distance
property real frontD: backD + weird.spanMin
property real step: weird.crawl // sign = direction of travel
property bool reaching: true // front moving, back planted
property bool loose: false // shoved off, ballistic
property real vx: 0
property real vy: 0
property real spin: 0 // rad/tick while airborne
}
// Every segment's position, rebuilt each tick. One array
// instead of per-segment bindings: the gait and the ballistic
// flight are different enough that computing them in the tick
// and handing over plain points is far less machinery than
// making both reactive.
property var body: []
// Body laid along the rail, hooped by however much slack the
// current compression leaves. index 0 is the head. `span` is
// signed, so everything below works crawling either way.
function railBody() {
let span = worm.frontD - worm.backD;
let dir = span >= 0 ? 1 : -1;
let squash = (spanMax - Math.abs(span)) / (spanMax - spanMin);
// He reaches past the hoop at both ends.
let from = worm.backD - dir * tailDrag;
let to = worm.frontD + dir * headLead;
let out = [];
for (let i = 0; i < segments; i++) {
let u = 1 - i / (segments - 1); // 1 at head, 0 at tail
let d = from + u * (to - from);
let base = railPoint(d);
// Lift only across the arching middle: sin() pins the
// hoop's own two ends down, and beyond them the neck
// and tail lie flat on the rail.
let v = (d - worm.backD) / span;
let lift = v > 0 && v < 1
? archMax * squash * Math.sin(Math.PI * v) : 0;
let n = railNormal(d);
out.push(Qt.point(base.x + n.x * lift, base.y + n.y * lift));
}
return out;
}
Timer {
interval: 33; running: true; repeat: true
onTriggered: {
if (worm.loose) {
worm.vy += 0.6; // gravity, px/tick²
let hx = body.length ? body[0].x : 0;
let hy = body.length ? body[0].y : 0;
hx += worm.vx; hy += worm.vy;
// The rail doubles as the walls he cannot leave
// the screen he was shoved off.
if (hx < railL) { hx = railL; worm.vx = -worm.vx * 0.5; }
if (hx > railR) { hx = railR; worm.vx = -worm.vx * 0.5; }
if (hy >= railB) {
hy = railB;
// Still moving: bounce. Spent: he is back on
// the floor, which is just the bottom run.
if (worm.vy > 3) { worm.vy = -worm.vy * 0.45; worm.vx *= 0.7; }
else {
worm.loose = false;
worm.backD = railAtBottom(hx);
worm.frontD = worm.backD + Math.sign(worm.step) * spanMin;
worm.reaching = true;
}
}
if (worm.loose) {
// Airborne he keeps the shape he was caught
// in and tumbles about his own head: map()
// reads the old array throughout, so body[0]
// stays the pivot and the head lands exactly
// on the new position.
let c = Math.cos(worm.spin), s = Math.sin(worm.spin);
let px = body[0].x, py = body[0].y;
body = body.map(p => {
let ox = p.x - px, oy = p.y - py;
return Qt.point(hx + ox * c - oy * s, hy + ox * s + oy * c);
});
} else {
body = railBody();
}
} else {
// Take turns: throw the front out to full stretch,
// then drag the back up to it.
if (worm.reaching) {
worm.frontD += worm.step;
if (Math.abs(worm.frontD - worm.backD) >= spanMax) worm.reaching = false;
} else {
worm.backD += worm.step;
if (Math.abs(worm.frontD - worm.backD) <= spanMin) worm.reaching = true;
}
body = railBody();
checkShove();
}
// Snapshot last, so a rect is always compared against
// where it was exactly one tick ago. vector4d is a
// value type, so this copies numbers, not handles.
prevRects = pushers.map(p => p.rect);
}
}
Repeater {
model: weird.segments
Rectangle {
id: seg
required property int index
readonly property var p: weird.body[index]
readonly property int size: weird.cell * (index < weird.segments - 3 ? 2 : 1)
visible: p !== undefined
width: size; height: size
// Snap to the grid sub-pixel motion would sand the
// pixels back off.
x: p ? Math.round((p.x - size / 2) / weird.cell) * weird.cell : 0
y: p ? Math.round((p.y - size / 2) / weird.cell) * weird.cell : 0
color: Theme.base0D
opacity: 1 - index * 0.05
antialiasing: false
Row { // eyes, head segment only
visible: seg.index === 0
anchors.centerIn: parent
spacing: 4
Repeater { model: 2; Rectangle { width: 2; height: 2; color: Theme.base00 } }
}
}
}
// Shoved by the panels
// Each pusher is { rect, push }: `rect` is the very vector4d
// the frame shader melts into the chrome (cx, cy, hw, hh;
// hw <= 0 means "not on screen"), so what sweeps him aside is
// the panel outline you can actually see. `push` is the unit
// direction that panel slides out in a constant of the
// design, and not something worth deriving: the morph is
// 280ms of OutExpo, nearly all of it landing inside a single
// 33ms tick, so there is rarely a partly-grown rect to read a
// direction off of.
property var pushers: []
property var prevRects: []
readonly property real shoveSpeed: 26
// Ticks before a panel may launch him again. Without it he can
// land back inside the same rect with his head over the edge
// and get booted forever.
property int shoveCool: 0
function launch(p) {
worm.loose = true;
worm.vx = p.push.x * shoveSpeed;
worm.vy = p.push.y * shoveSpeed - 8; // -8: sent up, not slid along
worm.spin = (0.04 + Math.random() * 0.05) * (p.push.x >= 0 ? 1 : -1);
shoveCool = 45;
}
function inRect(p, r) {
return Math.abs(p.x - r.x) <= r.z + 6 && Math.abs(p.y - r.y) <= r.w + 6;
}
function checkShove() {
if (shoveCool > 0) { shoveCool--; return; }
let tail = body[segments - 1];
for (let i = 0; i < pushers.length; i++) {
let r = pushers[i].rect, q = prevRects[i];
if (!r || r.z < 0.5) continue;
// Any part of him in the way is enough to be caught.
let touched = false;
for (let s = 0; s < body.length && !touched; s++)
touched = inRect(body[s], r);
if (!touched) continue;
// A panel popping out on top of him and him crawling
// into one already open both end the same way now:
// launched. Only the first is the moment you asked
// for, but a dropdown sits open for minutes while he
// crawls at well under a pixel a tick, so contact is
// the event you actually see and having that merely
// turn him round is what read as never being knocked.
//
// The exception is his whole body being inside, which
// means he landed in there: walking out is the way
// out, and kicking him again would only pin him.
if (!inRect(tail, r)) launch(pushers[i]);
return;
}
}
}
'';
};
"quickshell/Archie.qml" = {
onChange = qsRestart;
text = ''
import QtQuick
// Archie a black and tan dachshund with a red collar, living
// along the bottom of the screen. He trots about, wags, naps in
// his bed and visits his bowl.
//
// The art is plain text, one character per pixel, so he can be
// redrawn by editing the picture instead of editing code. He walks
// evenly on all fours and never limps; that was deliberate.
Item {
id: archie
anchors.fill: parent
// One dog. The bar is instantiated per monitor.
property bool primary: true
visible: primary
// 2px pixels: plenty of resolution for a proper pointed
// dachshund head, and he still lands smaller on screen than
// the chunkier drawing did.
readonly property int cell: 2
readonly property int cols: 44
readonly property int rows: 19
// Not themed he looked how he looked.
readonly property var pal: ({
"K": "#2b2523", // black
"T": "#a86a30", // tan
"R": "#b3322c", // collar, and his bowl
"N": "#14100f", // nose and eye
"B": "#96602f", // kibble
"G": "#5a667a", // bed
"D": "#3a4454" // bed cushion
})
// Trotting: front leg forward, hind leg back, tail up. Paws
// point the way he is going drawn trailing behind the leg
// they read as feet on backwards, which is exactly how the
// first pass looked.
readonly property var walkA: [
"...............................KKKKKKKK.....",
"KK............................KKKKKKKKKK....",
"KKKK..........................KKKKKKKKKKK...",
".KKKK.........................KKKKK.KKKKK...",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKK..TTKKKKK.",
".KKKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKTTTTTTTTT.",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKTTTTTTTT..",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKTTTTT.....",
"...KKKKKKKKKKKKKKKKKKKKKKKKKTTKKKK..........",
"...KKKKKKKKKKKKKKKKKKKKKKKKKTTTKKK..........",
"...KKKKKKKKKKKKKKKKKKKKKKKKTTTT.............",
".....KKKKKKKKKKKKKKKKKKKKKTTTT..............",
"....TTKKKKKKKKKKKKKKKKKKKTTTT...............",
".....TTTTT..............TTTT................",
"......TTTT..............TTT.................",
".....TTT................TTT.................",
".....TTT................TTT.................",
".....TTT................TTT.................",
".....TTTTT..............TTTTT..............."
]
// Legs gathered under him, tail level.
readonly property var walkB: [
"...............................KKKKKKKK.....",
"..............................KKKKKKKKKK....",
"KKKK..........................KKKKKKKKKKK...",
"KKKK..........................KKKKK.KKKKK...",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKK..TTKKKKK.",
".KKKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKTTTTTTTTT.",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKTTTTTTTT..",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKTTTTT.....",
"...KKKKKKKKKKKKKKKKKKKKKKKKKTTKKKK..........",
"...KKKKKKKKKKKKKKKKKKKKKKKKKTTTKKK..........",
"...KKKKKKKKKKKKKKKKKKKKKKKKTTTT.............",
".....KKKKKKKKKKKKKKKKKKKKKTTTT..............",
"....TTKKKKKKKKKKKKKKKKKKKTTTT...............",
"........TTTTT........TTTT...................",
".........TTTT........TTT....................",
"........TTT..........TTT....................",
"........TTT..........TTT....................",
"........TTT..........TTT....................",
"........TTTTT........TTTTT.................."
]
// Standing: walkA's planted legs, walkB's tail. Flipping
// between this and walkA is the wag.
readonly property var wag: [
"...............................KKKKKKKK.....",
"..............................KKKKKKKKKK....",
"KKKK..........................KKKKKKKKKKK...",
"KKKK..........................KKKKK.KKKKK...",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKK..TTKKKKK.",
".KKKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKTTTTTTTTT.",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKTTTTTTTT..",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKTTTTT.....",
"...KKKKKKKKKKKKKKKKKKKKKKKKKTTKKKK..........",
"...KKKKKKKKKKKKKKKKKKKKKKKKKTTTKKK..........",
"...KKKKKKKKKKKKKKKKKKKKKKKKTTTT.............",
".....KKKKKKKKKKKKKKKKKKKKKTTTT..............",
"....TTKKKKKKKKKKKKKKKKKKKTTTT...............",
".....TTTTT..............TTTT................",
"......TTTT..............TTT.................",
".....TTT................TTT.................",
".....TTT................TTT.................",
".....TTT................TTT.................",
".....TTTTT..............TTTTT..............."
]
// Head down in the bowl.
readonly property var eat: [
"............................................",
"KK..........................................",
"KKKK........................................",
".KKKK.......................................",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKKKKK............",
".KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKK...........",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKK..........",
"..KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKK..........",
"...KKKKKKKKKKKKKKKKKKKKKKKKKTTKKKK..........",
"...KKKKKKKKKKKKKKKKKKKKKKKKKTTTKKKK.........",
"...KKKKKKKKKKKKKKKKKKKKKKKKTTTTKKKKKKKK.....",
".....KKKKKKKKKKKKKKKKKKKKKTTTTKKKKKKKKKK....",
"....TTKKKKKKKKKKKKKKKKKKKTTTT.KKKKK.KKKK....",
".....TTTTT..............TTTT..KKKKK.TTTKK...",
"......TTTT..............TTT...KKKKTTTTTTTTT.",
".....TTT................TTT...KKKKKTTTTTTT..",
".....TTT................TTT....KKKKTTTT.....",
".....TTT................TTT.................",
".....TTTTT..............TTTTT..............."
]
// Flat out in his bed, muzzle on the cushion.
readonly property var sleepArt: [
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"..............................KKKKKKKKK.....",
".............................KKKKKKKKKKKK...",
"KKKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKKK.KKKKK...",
"..KKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKKTTTTTKKK..",
"..KKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKKTTTTTTTTT.",
"...KKKKKKKKKKKKKKKKKKKKKKKKRRKKKKKTTTTTTT...",
"...KKKKKKKKKKKKKKKKKKKKKKKKRRTTTT...........",
"....KKKKKKKKKKKKKKKKKKKKKKRRTTTT............",
".....TTTTTTTTKKKKKKKKKKKKTTTT...............",
"......TTTTTT..........TTTTTT................"
]
// Mid-breath: the same pose with his ribcage lifted one cell,
// just behind the collar where a dog's chest actually is. His
// belly stays put that side is against the cushion.
readonly property var sleepIn: [
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"............................................",
"..............................KKKKKKKKK.....",
"................KKKKKKKKKK...KKKKKKKKKKKK...",
"KKKKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKKK.KKKKK...",
"..KKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKKTTTTTKKK..",
"..KKKKKKKKKKKKKKKKKKKKKKKKKRRKKKKKTTTTTTTTT.",
"...KKKKKKKKKKKKKKKKKKKKKKKKRRKKKKKTTTTTTT...",
"...KKKKKKKKKKKKKKKKKKKKKKKKRRTTTT...........",
"....KKKKKKKKKKKKKKKKKKKKKKRRTTTT............",
".....TTTTTTTTKKKKKKKKKKKKTTTT...............",
"......TTTTTT..........TTTTTT................"
]
readonly property var bowlArt: [
".....BBBBBB.....",
"...BBBBBBBBBB...",
".BBBBBBBBBBBBBB.",
"RRRRRRRRRRRRRRRR",
".RRRRRRRRRRRRRR.",
"..RRRRRRRRRRRR..",
"...RRRRRRRRRR...",
"....RRRRRRRR....",
".....RRRRRR....."
]
readonly property var bedArt: [
"GGGGGGG........................................GGGGGGG",
"GGGGGGGGG....................................GGGGGGGGG",
"GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
"GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
"..GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG.."
]
property var frame: walkA
// His patch of floor
readonly property real floorY: archie.height - Theme.frameWidth
readonly property real roamL: Theme.barWidth + 12
readonly property real roamR: Math.max(roamL,
archie.width - Theme.frameWidth - cols * cell - 12)
// Bed tucked into the bottom right corner, bowl beside it
// his corner of the room. The bowl derives from the bed so
// they cannot drift into each other.
readonly property real bedX: Math.max(roamL,
archie.width - Theme.frameWidth - 54 * cell - 8)
readonly property real bowlX: Math.max(roamL, bedX - 16 * cell - 26)
// Stand so his muzzle lands in the bowl, not his shoulder.
readonly property real bowlStandX: bowlX - 32 * cell
// Lying down, centred on the cushion.
readonly property real bedLieX: bedX + 5 * cell
property real px: 0
property int facing: 1 // 1 = right, -1 = left
property string mode: "walk"
property real targetX: 0
property string errand: "" // "" | "bowl" | "bed"
property int hold: 0
// Ticks since he last got up. Past `tired` he takes himself
// off to bed.
property int awake: 0
readonly property int tired: 2700 // 90s on his feet
// Legs swap on distance covered, not on a tick count, so the
// gait stays in step. 3 cells is exactly how far the feet
// move in the art.
property real stride: 0
property bool stepped: false
// What he does next
function wander() {
mode = "walk";
errand = "";
// Bed is not a random errand any more, it is what he does
// when he has been up long enough. A dog sleeps most of
// the day; pottering about non-stop is the odd bit.
if (awake > tired) {
errand = "bed";
targetX = bedLieX;
return;
}
let dBowl = Math.abs(px - bowlStandX);
// Near enough to be worth it, and not already stood there
// otherwise he marches half a minute for a snack, or
// rolls "go to the bowl" with his head already in it.
if (Math.random() < 0.3 && dBowl > 80 && dBowl < 900) {
errand = "bowl";
targetX = bowlStandX;
return;
}
let dir = Math.random() < 0.5 ? -1 : 1;
// Turn away from an end he is already near; clamping the
// target instead just parks him against the wall.
if (dir < 0 && px - roamL < 220) dir = 1;
if (dir > 0 && roamR - px < 220) dir = -1;
targetX = Math.max(roamL, Math.min(roamR,
px + dir * (150 + Math.random() * 550)));
}
function arrive() {
if (errand === "bowl") {
mode = "eat";
hold = 100 + Math.random() * 80;
facing = 1;
} else if (errand === "bed") {
mode = "sleep";
hold = 9000 + Math.random() * 9000; // 5-10 minutes
// Facing into the screen. His bed is in the right
// corner, so unmirrored he sleeps nose-to-the-wall.
facing = -1;
} else {
mode = "idle";
hold = 150 + Math.random() * 450; // 5-20s stood about
}
}
// Placed on the first real width: anchors have not resolved at
// Component.onCompleted, so roamR collapses to roamL and he
// spawns pinned to the left edge.
property bool placed: false
onWidthChanged: {
if (placed || width <= 0) return;
placed = true;
px = roamL + Math.random() * (roamR - roamL);
wander();
}
Timer {
interval: 33; running: archie.primary; repeat: true
onTriggered: {
archie.awake = archie.mode === "sleep" ? 0 : archie.awake + 1;
if (archie.mode === "walk") {
// Arrival first: checking after the step makes him
// stride backwards when he is already there.
if (Math.abs(archie.targetX - archie.px) < 2.5) {
archie.arrive();
} else {
let dir = archie.targetX > archie.px ? 1 : -1;
let sp = 1.5;
archie.facing = dir;
archie.px += dir * sp;
archie.stride += sp;
if (archie.stride >= 3 * archie.cell) {
archie.stride = 0;
archie.stepped = !archie.stepped;
}
archie.frame = archie.stepped ? archie.walkB : archie.walkA;
}
} else {
// Breathing: in for a third of the cycle, then
// back down and a pause. 91 ticks is about three
// seconds a breath, which is where a small dog
// asleep actually sits.
archie.frame = archie.mode === "eat" ? archie.eat
: archie.mode === "sleep"
? (archie.hold % 91 < 32 ? archie.sleepIn : archie.sleepArt)
: (archie.hold % 26 < 13 ? archie.walkA : archie.wag);
if (--archie.hold <= 0) archie.wander();
}
}
}
// Sprite: one Rectangle per pixel, colour looked up per
// frame, so switching frames just rebinds them.
component Sprite: Item {
id: sp
property var art: []
property int w: 0
property int h: 0
width: w * archie.cell
height: h * archie.cell
Repeater {
// Delegates parent to the Repeater's PARENT, so `sp`
// is the only reliable way back to the sprite here.
model: sp.w * sp.h
Rectangle {
required property int index
readonly property int col: index % sp.w
readonly property int row: Math.floor(index / sp.w)
readonly property string ch: sp.art[row][col]
visible: ch !== "."
color: archie.pal[ch] || "transparent"
x: col * archie.cell
y: row * archie.cell
width: archie.cell
height: archie.cell
antialiasing: false
}
}
}
// Bed first, so he lies in it rather than behind it.
Sprite {
art: archie.bedArt; w: 54; h: 7
x: Math.round(archie.bedX / archie.cell) * archie.cell
y: archie.floorY - height
}
Sprite {
art: archie.frame
w: archie.cols
h: archie.rows
// Snap x to the pixel grid or the art shimmers as he
// walks. y is exact: he never moves vertically, and
// rounding sank his paws through the floor.
x: Math.round(archie.px / archie.cell) * archie.cell
y: archie.floorY - height
// Mirror him when he heads back the other way.
transform: Scale { origin.x: archie.cols * archie.cell / 2; xScale: archie.facing }
}
// Bowl after him, so at dinner his nose goes behind it.
Sprite {
art: archie.bowlArt; w: 16; h: 9
x: Math.round(archie.bowlX / archie.cell) * archie.cell
y: archie.floorY - height
}
}
'';
};
"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 wxFetch: "${wxFetchScript}"
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
// Twilight ramp: warmth fades in over this many minutes after
// sunset and back out over the same span before sunrise.
readonly property int nlFadeMins: 90
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();
}
// 0 in daylight, 1 in full night, linear in between across the
// nlFadeMins twilight windows. Handles the midnight wrap and
// nights shorter than two fade windows (min() just peaks lower).
function nightFactor(mins) {
let sr = sunriseMins, ss = sunsetMins;
let day = sr < ss ? (mins >= sr && mins < ss)
: (mins >= sr || mins < ss);
if (day) return 0;
let sinceSet = (mins - ss + 1440) % 1440;
let toRise = (sr - mins + 1440) % 1440;
return Math.min(1, sinceSet / nlFadeMins, toRise / nlFadeMins);
}
function applyNightlight() {
let w = nlState.warmth;
if (nlState.autoOn) {
let now = new Date();
let mins = now.getHours() * 60 + now.getMinutes();
w *= nightFactor(mins);
}
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"
// Hyprland doesn't render the top layer under a fullscreen
// window, so the OSD would be invisible exactly when a video
// is playing. Ride the overlay layer while it's on screen.
WlrLayershell.layer: osdItem.shown ? WlrLayer.Overlay : WlrLayer.Top
// 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
// Invoking an action ("View", "Reply")
// makes the app want focus, but it can't
// take it itself see focusNotifApp.
// closeAllDropdowns first for the same
// focus-grab reason as the list card; it's
// a no-op for the toast, which has no grab.
onClicked: {
bar.closeAllDropdowns();
modelData.invoke();
bar.focusNotifApp(_nc.notif);
_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 {
id: chromeSdf
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. 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.
//
// Scoped to ~/Documents and ~/Downloads, not all of $HOME.
// Searching $HOME drowned the results in game assets and
// nixos config files technically matches, never what was
// being looked for. These two are where documents a person
// goes hunting for by name actually live.
//
// A missing directory is fine: fd reports it on stderr, which
// nothing reads, and still searches the rest at exit 0.
//
// Dotfiles and dot-directories are also skipped (fd's default,
// there is deliberately no --hidden here).
readonly property var fdRoots: [
Quickshell.env("HOME") + "/Documents",
Quickshell.env("HOME") + "/Downloads"
]
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",
"--type", "f", "--type", "d",
"--max-results", String(fdMax),
// node_modules is the one junk tree that is neither
// hidden nor outside these roots.
"--exclude", "node_modules",
pat
].concat(fdRoots);
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
property bool wxDropNext: false
// Whose answer the card is still waiting on, if any. Only
// the slow paths count: Wikipedia lands inside the debounce
// and would just flicker the spinner.
readonly property bool answerPending: askProc.running || wxProc.running
readonly property string pendingSource: wxProc.running ? "open-meteo" : "Claude"
// 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;
// "weather" on its own is one short word, which the
// length rule below would throw out.
if (wantsWeather(q)) return true;
return q.split(/\s+/).length >= 2 && q.length >= 8;
}
function wantsWeather(q) {
return /\b(weather|forecast)\b/i.test(q);
}
// "weather", "weather in kristiansand", "what's the weather
// like right now" the same query with a varying amount of
// noise around an optional place name. Strip the noise and
// whatever survives is the place, or nothing at all, which
// wx-fetch reads as "wherever this machine is".
//
// The day words go too, even though only current conditions
// come back: leaving "tomorrow" in sends "oslo tomorrow" to
// the geocoder, which resolves nothing and answers with a
// blank card. Current conditions are the wrong answer to
// "tomorrow", so wx-fetch labels them "right now" rather
// than letting them pass as a forecast.
function weatherPlace(q) {
return q.toLowerCase()
.replace(/\b(what'?s|what|is|it|the|weather|forecast|temperature|temp|like|in|for|at|right|now|today|tonight|tomorrow|this|week|weekend|currently|outside)\b/g, " ")
.replace(/\s+/g, " ").trim();
}
// 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)
}
}
Process {
id: wxProc
stdout: StdioCollector {
onStreamFinished: launcherPanel.takeWx(text)
}
}
function killAnswer() {
if (!answerProc.running) return;
answerDropNext = true;
answerProc.running = false;
}
function killAsk() {
if (!askProc.running) return;
askDropNext = true;
askProc.running = false;
}
function killWx() {
if (!wxProc.running) return;
wxDropNext = true;
wxProc.running = false;
}
function clearAnswer() {
answerTimer.stop();
killAnswer();
killAsk();
killWx();
askWon = false;
answerFact = "";
answerLead = "";
answerTitle = "";
answerUrl = "";
answerSource = "";
}
function runAnswer() {
const q = answerKey(searchInput.text);
if (q === "" || !wantsAnswer(searchInput.text)) {
clearAnswer();
return;
}
answerQuery = q;
// Weather takes the open-meteo path alone: Claude has no
// network to look it up with, Wikipedia answers with the
// city's founding date, and the cache has no notion of
// age so it must not serve a temperature twice.
if (wantsWeather(q)) {
killAsk();
killAnswer();
killWx();
askWon = false;
wxProc.command = [Commands.wxFetch, weatherPlace(q)];
wxProc.running = true;
return;
}
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 takeWx(raw) {
if (wxDropNext) { wxDropNext = false; return; }
const text = raw.trim();
// Place didn't resolve, or open-meteo was unreachable.
if (text === "") return;
if (answerQuery !== answerKey(searchInput.text)) return;
// Deliberately not cached a temperature is stale in
// minutes and the cache never expires.
showAnswer({
title: "",
fact: text,
lead: "",
url: "",
source: "open-meteo"
});
}
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
// Header: who, and where. Both halves are baked in
// at build time the shell is generated per host,
// so there is nothing to look up at runtime.
// Snowflake is the same recolour trick as the bar's.
Row {
spacing: 8
Item {
width: 18
height: 18
anchors.verticalCenter: parent.verticalCenter
Image {
id: launchLogo
anchors.fill: parent
source: "file://" + Commands.nixLogo
sourceSize.width: 18
sourceSize.height: 18
smooth: true
visible: false
}
ColorOverlay {
anchors.fill: launchLogo
source: launchLogo
color: Theme.base0D
}
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: "${config.home.username}"
color: Theme.base0D
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: "@${osConfig.networking.hostName}"
color: Theme.base03
}
}
// 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
// Also up while a call is out with nothing on
// screen yet, so the spinner has somewhere to
// live during the first second.
visible: launcherPanel.answerFact !== "" || askProc.running || wxProc.running
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
visible: launcherPanel.answerFact !== ""
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. The spinner
// runs for as long as Claude's ~6s call is
// out, which is the only cue that a
// Wikipedia answer already on screen is
// still provisional and that a card that
// never changes was Claude declining, not
// Claude being slow.
Row {
spacing: 5
SIcon {
anchors.verticalCenter: parent.verticalCenter
visible: launcherPanel.answerPending
text: "loader-circle"
font.pixelSize: 11
color: Theme.base03
NumberAnimation on rotation {
from: 0; to: 360
duration: 900
loops: Animation.Infinite
running: launcherPanel.answerPending
}
}
SText {
anchors.verticalCenter: parent.verticalCenter
// Name what is still coming, next to
// whatever is already on screen.
text: launcherPanel.answerPending
? (launcherPanel.answerSource === "" ? launcherPanel.pendingSource
: launcherPanel.answerSource + " · " + launcherPanel.pendingSource)
: 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.
// KNOWN UNSOLVED: while an app that churns windows is running
// (Battle.net does it continuously), the launcher opens and this
// fires ~1ms later, so Super+R looks like it does nothing.
//
// Cause, from Hyprland 0.55.4 InputManager.cpp mouseMoveUnified():
//
// if (m_seatGrab && !m_seatGrab->accepts(foundSurface) ...) {
// if (m_hardInput || refocus) {
// g_pSeatManager->setGrab(nullptr);
//
// foundSurface is the surface under the CURSOR, and `refocus` is
// true for all 20 CInputManager::refocus() call sites, which
// include every window map and unmap. So each window a busy app
// churns destroys the grab unless the cursor happens to be over
// this window's input region. No user input is involved, which is
// why it is invisible from the client side. It also explains the
// workaround: click a bar widget first (cursor lands on the bar)
// and the launcher stays up.
//
// Tried and reverted, both failed:
// - suppress_event activate/activatefocus on the app (nothing is
// requesting activation, so there is nothing to suppress)
// - re-arming the grab on a suspiciously fast clear (the next
// refocus() kills the fresh grab too lost 3/3, every time)
// - expanding the input mask to the whole screen while open so
// accepts() is always true, with a MouseArea for outside
// clicks (behaviour changed, still died)
//
// Probably needs fixing upstream, or the launcher needs to stop
// depending on a seat grab at all.
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 "";
}
// Jump to the window that sent a notification. Toplevel.activate()
// goes over wlr-foreign-toplevel the same path a taskbar uses
// so Hyprland follows it across workspaces. The app raising
// itself does not work here: misc:focus_on_activate is off, so
// its own activation request is dropped on purpose.
function focusNotifApp(notif) {
if (!notif) return;
const want = [(notif.desktopEntry || "").replace(/\.desktop$/, ""), notif.appName]
.filter(s => s !== "").map(s => s.toLowerCase());
if (want.length === 0) return;
const tls = ToplevelManager.toplevels.values;
// Exact appId first: appName is a display name ("Vesktop"),
// appId the wayland id ("vesktop"), and they often differ by
// more than case, hence the substring pass.
for (const t of tls)
if (want.includes((t.appId || "").toLowerCase())) { t.activate(); return; }
for (const t of tls) {
const id = (t.appId || "").toLowerCase();
if (id !== "" && want.some(w => id.includes(w) || w.includes(id))) { t.activate(); return; }
}
}
// Clicking a notification body. Apps hide "open the right chat"
// behind the default action, so fire that too when they send one.
function activateNotif(notif) {
if (!notif) return;
for (const a of notif.actions)
if (a.identifier === "default") { a.invoke(); break; }
focusNotifApp(notif);
}
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, workspaces split by a hairline.
// 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 {}
// 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, clock, 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 {}
// 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();
}
}
}
}
// 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 {
id: calGrid
columns: 7
spacing: 0
// Offset of the 1st within its Mo-first week, and the
// whole-week count that covers the month so leading and
// trailing cells only spill into the weeks that need them.
property int startDay: (new Date(calPopup.viewYear, calPopup.viewMonth, 1).getDay() + 6) % 7
property int daysInMonth: new Date(calPopup.viewYear, calPopup.viewMonth + 1, 0).getDate()
Repeater {
model: 7 * Math.ceil((calGrid.startDay + calGrid.daysInMonth) / 7)
Rectangle {
required property int index
// Day-of-month offset; out-of-range values roll into
// the adjacent month via the Date constructor.
property int dayNum: index - calGrid.startDay + 1
property date cellDate: new Date(calPopup.viewYear, calPopup.viewMonth, dayNum)
property bool inMonth: dayNum >= 1 && dayNum <= calGrid.daysInMonth
property bool isToday: inMonth
&& 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.cellDate.getDate().toString()
color: parent.isToday ? Theme.base05 : (parent.inMonth ? Theme.base04 : Theme.base03)
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)
// First child, so the action chips and the
// dismiss button still take their own clicks.
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
// Close FIRST: the panel holds a
// HyprlandFocusGrab, and tearing that
// down refocuses whatever was focused
// before which undoes our activate if
// it already ran. animateClose() drops
// activeDropdown synchronously, so the
// grab is gone before the next line.
onClicked: {
bar.closeAllDropdowns();
bar.activateNotif(notifItem.modelData);
notifItem.modelData.dismiss();
}
}
// 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)
// First child, so the action chips and the dismiss
// button still take their own clicks.
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
bar.activateNotif(toastItem.currentNotif);
if (toastItem.currentNotif) toastItem.currentNotif.dismiss();
toastItem.dismiss();
}
}
// 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(); }
}
}
}
}
}
// Last child, so he crawls over the panels rather than under
// them. Purely decorative, see Weird.qml. The pushers are the
// same rects the frame shader draws, paired with the way each
// one slides out: dropdowns and toasts rightward out of the
// bar column, the launcher up out of the bottom frame band.
${lib.optionalString (!isMacbook) ''
Weird {
pushers: [
{ rect: chromeSdf.panel, push: Qt.point(1, 0) },
{ rect: chromeSdf.toast, push: Qt.point(1, 0) },
{ rect: chromeSdf.launcher, push: Qt.point(0, -1) }
]
}
// Archie. One dog, primary screen only.
Archie { id: archieDog; primary: bar.screen === Quickshell.screens[0] }
''}
}
'';
};
};
};
};
}