diff --git a/settings/quickshell.nix b/settings/quickshell.nix index a288d06..b947a7e 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -1,10 +1,56 @@ # 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, ... }: +{ config, pkgs, lib, inputs, ... }: let isMacbook = config.networking.hostName == "FredOS-Macbook"; ui = import ./ui.nix; # shared corner-rounding knob + + # ── Wallpaper set ────────────────────────────────────────────────────── + # Every image in walls/ gets its own dark base16 palette, generated at + # build time by stylix's own palette-generator. The shell picks one at + # RUNTIME (state file + FileView in Theme.qml), so switching wallpaper + # re-tints the whole shell without a rebuild. + # + # One derivation per image, not one for the set: palette generation is a + # genetic search that takes seconds, and per-image derivations mean adding + # a wallpaper only regenerates the new one. builtins.path hashes the image + # content alone, so unrelated repo edits don't invalidate any of them. + paletteGenerator = + inputs.stylix.packages.${pkgs.stdenv.hostPlatform.system}.palette-generator; + + wallFiles = builtins.attrNames (lib.filterAttrs + (n: t: t == "regular" + && lib.any (e: lib.hasSuffix e (lib.toLower n)) [ ".png" ".jpg" ".jpeg" ]) + (builtins.readDir (inputs.self + "/walls"))); + + # Store names allow a narrow charset, filenames don't. Keep wallpaper + # names plain — a space survives, anything more exotic may not. + wallStem = f: lib.head (lib.splitString "." f); + wallSlug = f: lib.replaceStrings [ " " ] [ "-" ] (wallStem f); + wallImage = f: builtins.path { + name = "wall-" + lib.replaceStrings [ " " ] [ "-" ] f; + path = inputs.self + "/walls/${f}"; + }; + wallPalette = f: pkgs.runCommand "palette-${wallSlug f}" { } '' + mkdir -p $out + ${paletteGenerator}/bin/palette-generator dark ${wallImage f} $out/palette.json + ''; + + # The list Theme.qml carries verbatim. Naming the store paths inside a + # home-manager file keeps both image and palette alive as generation + # references — no extra gcroot needed. + wallsJson = builtins.toJSON (map (f: { + name = wallStem f; + image = "${wallImage f}"; + palette = "${wallPalette f}/palette.json"; + }) wallFiles); + + # Falls back to walls/wallpaper.png — the one stylix themes everything + # else from — so a missing state file lands on the build-time scheme. + defaultWall = if lib.elem "wallpaper" (map wallStem wallFiles) + then "wallpaper" + else wallStem (lib.head wallFiles); in { config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) { @@ -383,25 +429,74 @@ in onChange = qsRestart; text = '' pragma Singleton + import Quickshell + import Quickshell.Io import QtQuick QtObject { + id: theme + + // ── Wallpapers ───────────────────────────────────────── + // Built in settings/quickshell.nix: one entry per image in + // walls/, each with a dark base16 palette generated from it. + readonly property var walls: ${wallsJson} + + // Which one is live. Persisted so the choice survives a + // reboot; every colour below is a binding on `pal`, so + // writing this file re-tints the entire shell in place. + property FileView wallFile: FileView { + path: Quickshell.env("HOME") + "/.local/state/quickshell-wallpaper.json" + printErrors: false + // Synchronous: the palette load chains off this, and an + // async hop here means one frame painted in the wrong + // scheme on every startup. + blockLoading: true + adapter: JsonAdapter { + id: wallState + property string name: "${defaultWall}" + } + onLoadFailed: writeAdapter() + } + + // Unknown name (wallpaper deleted from the repo since it was + // picked) falls back to the first entry rather than blanking. + readonly property var currentWall: + walls.find(w => w.name === wallState.name) || walls[0] || null + readonly property string wallImage: currentWall ? currentWall.image : "" + + function setWall(name) { + wallState.name = name; + wallFile.writeAdapter(); + } + + property var pal: ({}) + property FileView palFile: FileView { + path: theme.currentWall ? theme.currentWall.palette : "" + printErrors: false + blockLoading: true + onLoaded: theme.pal = JSON.parse(text()) + } + // ── 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}" + // + // The "#${c.base00}" fallbacks are stylix's build-time scheme + // (walls/wallpaper.png): what shows for the frame or two + // before palFile lands, and if a palette file ever goes bad. + readonly property color base00: "#" + (pal.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}" + readonly property color base03: "#" + (pal.base03 || "${c.base03}") + readonly property color base04: "#" + (pal.base04 || "${c.base04}") + readonly property color base05: "#" + (pal.base05 || "${c.base05}") // The one themed accent: active, focused, selected, filled. - readonly property color base0D: "#${c.base0D}" + readonly property color base0D: "#" + (pal.base0D || "${c.base0D}") // ── Status colours: fixed hues, pulled toward the theme. // Sourcing these from stylix doesn't work — base08-base0F all @@ -538,7 +633,7 @@ in Image { id: wall anchors.fill: parent - source: "file://" + Quickshell.env("HOME") + "/.local/share/backgrounds/wallpaper.png" + source: "file://" + Theme.wallImage // 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. @@ -1485,7 +1580,7 @@ in Image { anchors.fill: parent - source: "file://" + Quickshell.env("HOME") + "/.local/share/backgrounds/wallpaper.png" + source: "file://" + Theme.wallImage cache: false // same-URL pixmap cache survives soft reload fillMode: Image.PreserveAspectCrop } @@ -1947,6 +2042,9 @@ in // ears / border-gaps / melt geometry lives in the math now. ShaderEffect { id: chromeSdf + // DIAGNOSTIC ONLY — revert. Measuring how much of the + // shell's idle GPU cost is this full-screen SDF pass. + visible: false anchors.fill: parent // Panels grow rightward out of the bar column, so the // cross-axis is now vertical: the 8px inset that used to @@ -3833,6 +3931,36 @@ in } } + BarSep {} + + // Wallpaper picker. Sits under the workspaces because it + // grows downward with them — the mid/bottom clusters + // anchor from the other end and never move. + Item { + id: wallBtn + width: Theme.barWidth + height: 24 + + SIcon { + anchors.centerIn: parent + text: "image" + font.pixelSize: 18 + color: bar.activeDropdown === wallDropdown ? Theme.base0D + : wallMa.containsMouse ? Theme.base05 : Theme.base04 + Behavior on color { ColorAnimation { duration: Theme.animFade } } + } + + MouseArea { + id: wallMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: bar.toggleDropdown(wallDropdown, function() { + let pos = wallBtn.mapToItem(bar.contentItem, 0, wallBtn.height / 2); + wallDropdown.dropdownY = pos.y; + }) + } + } } // ── Mid cluster: the server glance, pinned just above the @@ -4992,6 +5120,78 @@ in } } + // Wallpaper picker — thumbnail grid. Clicking a tile writes + // the state file, which re-binds Theme's whole palette: the + // wallpaper, the bar and every open panel re-tint together, + // no rebuild and no restart. + BarDropdown { + id: wallDropdown + fullWidth: wallCard.width + 2 * Theme.panelGap + fullHeight: wallCard.height + 2 * Theme.panelGap + + Rectangle { + id: wallCard + anchors.centerIn: parent + width: wallGrid.width + 2 * Theme.cardPad + height: wallGrid.height + 2 * Theme.cardPad + radius: Theme.radiusCard + color: Theme.cardBg + + Grid { + id: wallGrid + anchors.centerIn: parent + // Two up: a 16:9 thumb wide enough to recognise + // still leaves the panel narrower than the + // launcher, and the column stays short. + columns: 2 + spacing: 6 + + Repeater { + model: Theme.walls + + ClippingRectangle { + id: wallTile + required property var modelData + readonly property bool active: + Theme.currentWall && Theme.currentWall.name === modelData.name + + width: 124 + height: 70 + radius: Theme.radiusTiny + color: Theme.base01 + // Accent ring on the live one, base04 on + // hover — same selection language as the + // workspace pill above it. + border.width: Theme.borderWidth + border.color: active ? Theme.base0D + : wallTileMa.containsMouse ? Theme.base04 + : "transparent" + Behavior on border.color { ColorAnimation { duration: Theme.animFade } } + + Image { + anchors.fill: parent + source: "file://" + wallTile.modelData.image + // Decode at thumbnail size: the full + // 4K texture per tile is what makes a + // picker like this cost real VRAM. + sourceSize.width: 248 + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + + MouseArea { + id: wallTileMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Theme.setWall(wallTile.modelData.name) + } + } + } + } + } + } + // Volume dropdown // Server monitor dropdown — the btop-at-a-distance view. BarDropdown {