diff --git a/doc/src/modules.md b/doc/src/modules.md index c7078888..ae29e80c 100644 --- a/doc/src/modules.md +++ b/doc/src/modules.md @@ -41,15 +41,34 @@ folder, using any name which is not on the list above. ## Module template -All modules should have an enable option created using `mkEnableTarget`. This is -similar to -[`mkEnableOption`](https://nix-community.github.io/docnix/reference/lib/options/lib-options-mkenableoption/) -from the standard library, however it integrates with -[`stylix.enable`](./options/nixos.md#stylixenable) and -[`stylix.autoEnable`](./options/nixos.md#stylixautoenable) and generates more -specific documentation. +Modules should be created using the `mkTarget` function whenever possible (see +the [`/stylix/mk-target.nix`]( +https://github.com/danth/stylix/blob/-/stylix/mk-target.nix) in-source +documentation for more details): -A general format for modules is shown below. +```nix +{ config, lib, mkTarget ... }: +mkTarget { + name = "«name»"; + humanName = "«human readable name»"; + + configElements = + { colors }: + { + programs.«name».theme.background = colors.base00; + }; +} +``` + +> [!IMPORTANT] +> The `mkTarget` argument is only available to modules imported by Stylix's +> [autoload system](https://github.com/danth/stylix/blob/-/stylix/autoload.nix), +> e.g., `modules/«target»/«platform».nix` modules. +> +> I.e., it is not available to normal modules imported via the `imports` list. + +When the `mkTarget` function cannot be used, modules must manually replicate its +safeguarding behaviour: ```nix { config, lib, ... }: @@ -66,8 +85,8 @@ A general format for modules is shown below. ``` > [!CAUTION] -> You **must** check _both_ `config.stylix.enable` _and_ your target's own -> `enable` option before defining any config. +> If not using `mkTarget`, you **must** check _both_ `config.stylix.enable` +> _and_ your target's own`enable` option before defining any config. > > In the above example this is done using > `config = lib.mkIf (config.stylix.enable && config.stylix.targets.«name».enable)`. diff --git a/stylix/autoload.nix b/stylix/autoload.nix index 98fb8f5d..5d952e05 100644 --- a/stylix/autoload.nix +++ b/stylix/autoload.nix @@ -8,7 +8,44 @@ builtins.concatLists ( path: kind: let file = "${inputs.self}/modules/${path}/${for}.nix"; + module = import file; + + # Detect whether the file's value has an argument named `mkTarget` + useMkTarget = + builtins.isFunction module && (builtins.functionArgs module) ? mkTarget; + + # NOTE: `mkTarget` cannot be distributed normally through the module system + # due to issues of infinite recursion. + mkTarget = import ./mk-target.nix; in - lib.optional (kind == "directory" && builtins.pathExists file) file + lib.optional (kind == "directory" && builtins.pathExists file) ( + if useMkTarget then + { config, ... }@args: + let + # Based on `lib.modules.applyModuleArgs` + # + # Apply `mkTarget` as a special arg without actually using `specialArgs`, + # which cannot be defined from within a configuration. + context = + name: ''while evaluating the module argument `${name}' in "${toString file}":''; + extraArgs = lib.pipe module [ + builtins.functionArgs + (lib.flip builtins.removeAttrs [ "mkTarget" ]) + (builtins.mapAttrs ( + name: _: + builtins.addErrorContext (context name) ( + args.${name} or config._module.args.${name} + ) + )) + ]; + in + { + key = file; + _file = file; + imports = [ (module (args // extraArgs // { inherit mkTarget; })) ]; + } + else + file + ) ) (builtins.readDir "${inputs.self}/modules") ) diff --git a/stylix/mk-target.nix b/stylix/mk-target.nix new file mode 100644 index 00000000..acd0b5d9 --- /dev/null +++ b/stylix/mk-target.nix @@ -0,0 +1,227 @@ +/** + Provides a consistent target interface, minimizing boilerplate and + automatically safeguarding declarations related to disabled options. + + # Type + + ``` + mkTarget :: AttrSet -> ModuleBody + ``` + + Where `ModuleBody` is a module that doesn't take any arguments. This allows + the caller to use module arguments. + + # Examples + + The `modules/«MODULE»/«PLATFORM».nix` modules should use this function as + follows: + + ```nix + { mkTarget, lib... }: + mkTarget { + name = "«name»"; + humanName = "«human readable name»"; + + generalConfig = + lib.mkIf complexCondition { + home.packages = [ pkgs.hello ]; + }; + + configElements = [ + { programs.«name».theme.name = "stylix"; } + + ( + { colors }: + { + programs.«name».theme.background = colors.base00; + } + ) + + ( + { fonts }: + { + programs.«name».font.name = fonts.monospace.name; + } + ) + ]; + } + ``` + + # Inputs + + `config` (Attribute set) + + : `name` (String) + : The target name used to generate options in the `stylix.targets.${name}` + namespace. + + `humanName` (String) + : The descriptive target name passed to the lib.mkEnableOption function + when generating the `stylix.targets.${name}.enable` option. + + `autoEnable` (Boolean) + : Whether the target should be automatically enabled by default according + to the `stylix.autoEnable` option. + + This should be disabled if manual setup is required or if auto-enabling + causes issues. + + `extraOptions` (Attribute set) + : Additional options to be added in the `stylix.targets.${name}` namespace + along the `stylix.targets.${name}.enable` option. + + For example, an extension guard used in the configuration can be declared + as follows: + ```nix + { extension.enable = lib.mkEnableOption "the bloated dependency"; } + ``` + + `configElements` (List or attribute set or function) + : Configuration functions that are automatically safeguarded when any of + their arguments is disabled. The provided `cfg` argument conveniently + aliases to `config.stylix.targets.${name}`. + + For example, the following configuration is not merged if the stylix + colors option is null: + + ```nix + ( + { colors }: + { + programs.«name».theme.background = colors.base00; + } + ) + ``` + + The `cfg` alias can be accessed as follows: + + ```nix + ( + { cfg }: + { + programs.«name».extension.enable = cfg.extension.enable; + } + ) + ``` + + `generalConfig` (Attribute set or function) + : This argument mirrors the `configElements` argument but intentionally + lacks automatic safeguarding and should only be used for complex + configurations where `configElements` is unsuitable. + + # Environment + + The function is provided alongside module arguments in any modules imported + through `/stylix/autoload.nix`. +*/ + +# TODO: Ideally, in the future, this function returns an actual module by better +# integrating with the /stylix/autoload.nix logic, allowing the following target +# simplification and preventing access to unguarded module arguments by +# requiring /modules//.nix files to be attribute sets instead +# of modules: +# +# { +# name = "example"; +# humanName = "Example Target"; +# +# generalConfig = +# { lib, pkgs }: +# lib.mkIf complexCondition { +# home.packages = [ pkgs.hello ]; +# }; +# +# configElements = [ +# { programs.example.theme.name = "stylix"; } +# +# ( +# { colors }: +# { +# programs.example.theme.background = colors.base00; +# } +# ) +# +# ( +# { fonts }: +# { +# programs.example.font.name = fonts.monospace.name; +# } +# ) +# ]; +# } +{ + name, + humanName, + autoEnable ? true, + extraOptions ? { }, + configElements ? [ ], + generalConfig ? null, +}: +let + module = + { config, lib, ... }: + let + cfg = config.stylix.targets.${name}; + + # Get the list of function de-structured argument names. + functionArgNames = + fn: + lib.pipe fn [ + lib.functionArgs + builtins.attrNames + ]; + + getStylixAttrs = + fn: + lib.genAttrs (functionArgNames fn) ( + arg: + if arg == "cfg" then + cfg + else if arg == "colors" then + config.lib.stylix.colors + else + config.stylix.${arg} + or (throw "stylix: mkTarget expected one of `cfg`, `colors`, ${ + lib.concatMapStringsSep ", " (name: "`${name}`") ( + builtins.attrNames config.stylix + ) + }, but got: ${arg}") + ); + + # Call the configuration function with its required Stylix arguments. + mkConfig = fn: fn (getStylixAttrs fn); + + # Safeguard configuration functions when any of their arguments is + # disabled, while non-function configurations are unguarded. + mkConditionalConfig = + c: + if builtins.isFunction c then + let + allAttrsNonNull = lib.pipe c [ + getStylixAttrs + builtins.attrValues + (builtins.all (attr: attr != null)) + ]; + in + lib.mkIf allAttrsNonNull (mkConfig c) + else + c; + in + { + options.stylix.targets.${name}.enable = + config.lib.stylix.mkEnableTarget humanName autoEnable; + + config = lib.mkIf (config.stylix.enable && cfg.enable) ( + lib.mkMerge ( + lib.optional (generalConfig != null) (mkConfig generalConfig) + ++ map mkConditionalConfig (lib.toList configElements) + ) + ); + }; +in +{ + imports = [ + { options.stylix.targets.${name} = extraOptions; } + module + ]; +}