main
  1# User color theme control system.
  2#
  3# Users pick a theme via `den.users.<name>.system.settings.themes.theme`.
  4# Any aspect may register themed content as `den.aspects.<path>.themes.<name>`
  5# sub-aspects (the `themes` key is reserved so the registry is never treated
  6# as pipeline content or settings). When the configured theme matches a
  7# registered theme name, the controller automatically imports that theme
  8# aspect into the user scope.
  9{
 10  den,
 11  lib,
 12  ...
 13}: let
 14  # Keys the scan must not descend into.
 15  scanSkip = [
 16    "_"
 17    "_module"
 18    "classes"
 19    "excludes"
 20    "includes"
 21    "meta"
 22    "name"
 23    "policies"
 24    "provides"
 25    "settings"
 26    "themes"
 27  ];
 28
 29  # Normalize a navigated aspect ref into `{ name, meta.provider }` form so
 30  # guard-time hasAspect (identity.key) matches the identity the walker
 31  # assigns to the same node (top-level aspects already carry name/meta;
 32  # nested content wrappers only carry __provider).
 33  normalizeRef = v:
 34    if
 35      builtins.isAttrs v
 36      && !(v ? name)
 37      && builtins.isList (v.__provider or [])
 38      && v.__provider != []
 39    then
 40      v
 41      // {
 42        name = lib.last v.__provider;
 43        meta =
 44          (v.meta or {})
 45          // {
 46            provider = lib.init v.__provider;
 47          };
 48      }
 49    else v;
 50
 51  # Collect every `themes.<name>` aspect in the tree. One scan at module
 52  # evaluation, reused by every user scope.
 53  collectThemes = path: node:
 54    if !(builtins.isAttrs node)
 55    then []
 56    else let
 57      registry = node.themes or null;
 58      mine =
 59        if builtins.isAttrs registry
 60        then
 61          lib.mapAttrsToList (name: theme: {
 62            inherit name;
 63            owner = node;
 64            inherit theme;
 65          })
 66          registry
 67        else [];
 68      recurse = builtins.concatLists (map (k: collectThemes (path ++ [k]) node.${k}) (
 69        builtins.filter (
 70          k:
 71            !(builtins.elem k scanSkip)
 72            && builtins.isAttrs node.${k}
 73            && !(lib.hasPrefix "__" k)
 74        ) (builtins.attrNames node)
 75      ));
 76    in
 77      mine ++ recurse;
 78
 79  candidates = collectThemes [] den.aspects;
 80
 81  # Fire at every user (and home) scope. Reads the user's theme choice and
 82  # re-emits the matching registrations as guarded aspects — an aspect only
 83  # activates when its owner (`xyz`) is part of the user's or host's tree.
 84  controller = {
 85    host,
 86    user,
 87    ...
 88  }: let
 89    theme = user.system.settings.themes.theme or null;
 90    matched = builtins.filter (t: t.name == theme) candidates;
 91  in {
 92    name = "themes-controller/${user.userName}@${host.name}";
 93    includes = map (t:
 94      den.lib.policy.when ({host, ...}: host.hasAspect (normalizeRef t.owner)) t.theme)
 95    matched;
 96  };
 97in {
 98  den.reservedKeys = ["themes"];
 99
100  den.aspects.themes.settings.user.theme = lib.mkOption {
101    type = lib.types.nullOr lib.types.str;
102    default = null;
103    description = "Color theme to activate for this user. Matching `themes.<name>` aspects are imported automatically.";
104  };
105
106  den.default.includes = [controller];
107}