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}