# 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, inputs, ... }: let isMacbook = config.networking.hostName == "FredOS-Macbook"; ui = import ./ui.nix; # shared corner-rounding knob # ── Wallpapers ───────────────────────────────────────────────────────── # Built in settings/theming.nix: one entry per image in walls/, each with # a theme directory holding that wallpaper's base16 palette and the config # files every other app is re-themed from. The shell picks one at RUNTIME # (state file + FileView in Theme.qml), so switching re-tints the desktop # without a rebuild. # # Naming the store paths inside a home-manager file is what keeps the # images and themes alive as generation references — no extra gcroot. wallsJson = builtins.toJSON config.fred.theming.walls; defaultWall = config.fred.theming.defaultWall; themeApply = config.fred.theming.apply; monoIcons = config.fred.theming.monoIcons; # Every icon name YAMIS ships, as a QML singleton. # # AppIcon needs to know whether a given icon came from the monochrome set # (safe to flatten to one colour) or fell through to Papirus (not — see the # component). It cannot tell from the resolved source: Quickshell.iconPath # returns an "image://icon/" provider URI, never a filesystem path, # so there is no store path or theme directory in it to match on. The name # is all we get, so the name is what we test. # # Membership is the whole predicate: YAMIS leads Papirus-Fred's Inherits, so # a name it ships is a name it serves. Every directory counts, not just # apps/ — desktop entries reach into the other contexts freely (nm-connection # -editor asks for preferences-system-network, which lives in preferences/), # and an apps-only list left those rendering at the baked #d8dee9 while # everything around them tinted. # # places/ is the exception: Papirus-Fred overrides the folder faces in its # own directory, which outranks anything inherited, so those resolve to the # wallpaper-coloured Papirus artwork and flattening them would undo the tint. # # Built here rather than listed at eval time: reading the icon tree during # evaluation would mean IFD, and hosts evaluate this straight from the # Forgejo remote. monoIconNames = pkgs.runCommand "MonoIcons.qml" { } '' { echo "pragma Singleton" echo "import QtQuick" echo "QtObject {" printf ' readonly property var names: [' find ${monoIcons} -path '*/places/*' -prune -o -name '*.svg' -print \ | while read -r f; do basename "$f" .svg; done \ | sort -u | while read -r n; do printf '"%s",' "$n"; done printf ']\n' echo "}" } > $out ''; 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; # Stylix sets QT_QPA_PLATFORMTHEME=qt5ct system-wide, so Qt asks # qt6ct for the icon theme and never sees the gsettings value. # Quickshell honours this override; without it the Papirus-Fred → # YAMIS Inherits chain is bypassed entirely. Environment = [ "QS_ICON_THEME=Papirus-Fred" ]; }; 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" ''; # Screen recording, driven by the quick-settings card. The shell execs # into wf-recorder so the card's SIGINT lands on the recorder itself — # wf-recorder finalises the mp4 on SIGINT, killing a wrapper instead # would leave a truncated file. The path is echoed before the exec so # the card can offer the result. Audio means the default sink's monitor # (desktop sound), not the microphone. screenRecScript = pkgs.writeShellScript "screen-rec" '' out="$HOME/Videos/rec-$(${pkgs.coreutils}/bin/date +%Y%m%d-%H%M%S).mp4" ${pkgs.coreutils}/bin/mkdir -p "$HOME/Videos" audio="" if [ "$2" = audio ]; then sink=$(${pkgs.wireplumber}/bin/wpctl inspect @DEFAULT_AUDIO_SINK@ \ | ${pkgs.gnused}/bin/sed -n 's/.*node\.name = "\(.*\)".*/\1/p') [ -n "$sink" ] && audio="--audio=$sink.monitor" fi if [ "$1" = region ]; then geom=$(${pkgs.slurp}/bin/slurp) || exit 1 echo "$out" exec ${pkgs.wf-recorder}/bin/wf-recorder $audio -g "$geom" -f "$out" fi echo "$out" exec ${pkgs.wf-recorder}/bin/wf-recorder $audio -f "$out" ''; 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