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