Commit 12b1012

HPCesia <me@hpcesia.com>
2026-06-17 11:00:44
Add user scope theme control
1 parent f4b8c99
Changed files (3)
modules
modules/users/hpcesia/default.nix
@@ -22,6 +22,7 @@
     };
     system = {
       hashedPasswordAged = ./hashed-password.age;
+      themes.theme = "catppuccin";
     };
   };
 }
modules/users/default.nix
@@ -1,4 +1,81 @@
-{den, ...}: {
+{
+  den,
+  lib,
+  ...
+}: let
+  # Walk den.aspects (at pipeline time) for .themes sub-aspects
+  # and include the ones selected in user.system.themes.
+  # user.system.themes.theme = global default; per-aspect keys override it.
+  themeIncludes = {user, ...}: let
+    themesCfg = user.system.themes or {};
+    globalTheme = themesCfg.theme or null;
+
+    inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet;
+    classKeys = den.classes or {};
+    quirkKeys = den.quirks or {};
+    skipKey = k: lib.hasPrefix "__" k || structuralKeysSet ? ${k} || classKeys ? ${k} || quirkKeys ? ${k} || k == "settings" || k == "provides";
+
+    # Collect paths of aspects that have .themes (non-recursive flat walk).
+    collectThemePaths = let
+      go = prefix: node:
+        if !builtins.isAttrs node
+        then []
+        else let
+          hasThemes = node ? themes && builtins.isAttrs node.themes;
+          pathStr = lib.concatStringsSep "." prefix;
+          here =
+            if hasThemes
+            then [pathStr]
+            else [];
+          children = lib.concatMap (
+            k:
+              if skipKey k
+              then []
+              else go (prefix ++ [k]) node.${k}
+          ) (builtins.attrNames node);
+        in
+          here ++ children;
+    in
+      go [] (den.aspects or {});
+
+    # Resolve effective theme for an aspect path — walks the tree structure.
+    effectiveTheme = path: let
+      segs = lib.splitString "." path;
+      explicit = lib.attrByPath segs null themesCfg;
+    in
+      if explicit != null
+      then explicit
+      else globalTheme;
+
+    includeThemeAspect = path: theme: let
+      segs = lib.splitString "." path;
+      parentAspect = lib.attrByPath segs null den.aspects;
+      themeAspect = lib.attrByPath (segs ++ ["themes" theme]) null den.aspects;
+      # some den.aspects.<path> node carries no name/meta.provider
+      parentRef = {
+        name = lib.last segs;
+        meta.provider = lib.init segs;
+      };
+    in
+      lib.optionals (themeAspect != null && parentAspect != null) (
+        den.lib.policy.when
+        ({user, ...}: user.hasAspect parentRef)
+        [themeAspect]
+      );
+  in {
+    name = "users/themes";
+    includes =
+      lib.concatMap (
+        path: let
+          t = effectiveTheme path;
+        in
+          if t != null
+          then includeThemeAspect path t
+          else []
+      )
+      collectThemePaths;
+  };
+in {
   den.default.includes = [
     den.batteries.define-user
     ({user, ...}: {
@@ -15,5 +92,6 @@
         vaultix.beforeUserborn = [secretName];
       };
     })
+    themeIncludes
   ];
 }
modules/users/schema.nix
@@ -59,6 +59,22 @@ in {
                 default = {};
                 description = "Per-user feature settings (freeform nested namespace)";
               };
+              themes = mkOption {
+                type = types.attrs;
+                default = {};
+                description = ''
+                  Per-aspect theme selection (tree structure mirroring aspect tree).
+                  Leaf values are theme names (strings). The special key `theme` at the
+                  root sets the global default theme applied to all aspects that do not
+                  have a per-aspect override.
+                '';
+                example = lib.literalExpression ''
+                  {
+                    theme = "catppuccin";
+                    develop.editor.helix = "tokyo-night";
+                  }
+                '';
+              };
             };
           };
           default = {};