# 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; 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