diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b67efa..db7d5b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,23 @@ ### Added +- `lib/modules/styling.nix`: cross-cutting styling module, imported into + every wrapper. Provides `styling.scheme` (a base16 scheme by name from + `pkgs.base16-schemes`, or by path), `styling.palette`, + `styling.colors` (semantic aliases plus the 16 ANSI colours), + `styling.polarity`, `styling.fonts`, `styling.opacity` and + `styling.cursor`. `styling.enable` turns on as soon as a scheme is set and + can be switched off per wrapper, so wrappers with no scheme configured are + unaffected. See the Styling section of the README. +- `wlib.applyStyle`: apply one settings module to a set of wrapper modules at + once, so a single theme can reach every program. +- `modules/alacritty`: derives colours, font and opacity from `styling` in a + separate `styling.nix`, as the worked example of a styled module. Also + gains a `check.nix`, which it did not have before. +- `wlib.style`: base16 scheme parsing (`parseScheme`, `resolveScheme`, + `slots`) and colour formatting helpers (`withHash`, `toRGB`, `rgbCss`, + `rgbaCss`, `withAlpha`, `formatNumber`, `luminance`, `isDark`, + `mkDefaults`). Scheme loading is pure, with no import from derivation. - `lib/modules/command.nix`: base module with shared command spec (args, env, hooks, exePath) used by both wrapper and systemd outputs. - `lib/modules/flags.nix`: flags module with per-flag ordering via diff --git a/README.md b/README.md index bde5a531..1b8351a7 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,9 @@ Built-in options (always available): - `wrapper`: The resulting wrapped package (read-only, auto-generated from other options) - `apply`: Function to extend the configuration with additional modules (read-only) +Always available, see [Styling](#styling): +- `styling`: Shared colour scheme, fonts, opacity and cursor settings + Optional modules (import via `wlib.modules.`): - `systemd`: Generates systemd service files (user and/or system), options are passed through from NixOS @@ -368,6 +371,216 @@ in { } ``` +## Styling + +Every wrapper module has a `styling` option group: one colour scheme, font, +opacity and cursor definition that programs derive their own configuration +from. It plays the role stylix plays for NixOS and home-manager, but stays +inside the wrapper module system, so it works the same on nixos, home-manager, +nix-darwin, devenv, or a bare `nix build`. + +Wrapper modules are evaluated independently of each other, so there is no +shared configuration for a theme to live in. `wlib.applyStyle` maps one +definition over as many modules as you like: + +```nix +{ pkgs, wrappers, ... }: +let + themed = wrappers.lib.applyStyle { + inherit pkgs; + styling = { + scheme = "gruvbox-dark-hard"; + fonts.monospace = { + package = pkgs.jetbrains-mono; + name = "JetBrains Mono"; + }; + fonts.sizes.terminal = 12; + opacity.terminal = 0.9; + }; + } { inherit (wrappers.wrapperModules) alacritty foot rofi waybar; }; +in +[ + themed.alacritty.wrapper + themed.foot.wrapper + themed.rofi.wrapper + themed.waybar.wrapper +] +``` + +The results are ordinary configs, so a single program can still be refined +afterwards without losing the shared theme: + +```nix +themed.alacritty.apply { + # keep the scheme, but with a black background + styling.palette.base00 = "000000"; +} +``` + +Nothing forces you to use the helper. `styling` is a normal option group, so +passing it to a single `apply` works too: + +```nix +(wrappers.wrapperModules.foot.apply { + inherit pkgs; + styling.scheme = "gruvbox-dark-hard"; +}).wrapper +``` + +### Colour scheme + +`styling.scheme` accepts the name of any scheme in `pkgs.base16-schemes`, or a +path or derivation holding a base16 YAML file: + +```nix +styling.scheme = "gruvbox-dark-hard"; +styling.scheme = ./my-scheme.yaml; +``` + +It populates `styling.palette.base00` through `styling.palette.base0F`. Those +are ordinary options, so any individual colour can be changed without giving up +the scheme: + +```nix +styling.scheme = "gruvbox-dark-hard"; +styling.palette.base0D = "abcdef"; +``` + +Colours are stored as lower case 6 digit hex **without** a `#` prefix, because +that is the one form every config format can be derived from. Add the prefix +you need with the helpers below. + +`styling.colors` exposes the same palette under semantic names, which is what +modules should read — `colors.background` says what a value means where +`palette.base00` does not: + +| | | | | +|---|---|---|---| +| `background` base00 | `backgroundAlt` base01 | `selection` base02 | `comment` base03 | +| `foregroundAlt` base04 | `foreground` base05 | `foregroundBright` base06 | `backgroundBright` base07 | +| `red` base08 | `orange` base09 | `yellow` base0A | `green` base0B | +| `cyan` base0C | `blue` base0D | `magenta` base0E | `brown` base0F | +| `accent` base0D | `error` base08 | `warning` base0A | `success` base0B | +| `info` base0C | | | | + +`styling.colors.ansi` additionally provides the standard base16 mapping onto +the 16 ANSI terminal colours (`black`, `red`, … `white`, `brightBlack`, … +`brightWhite`), so terminals do not each have to invent their own. + +### Options + +| Option | Default | Description | +|---|---|---| +| `styling.enable` | `styling.scheme != null` | Whether this wrapper styles itself | +| `styling.scheme` | `null` | Scheme name, path, or derivation | +| `styling.palette.base00`–`base0F` | from the scheme | Individual colours | +| `styling.colors` | derived, read only | Semantic aliases and `colors.ansi` | +| `styling.polarity` | from the scheme | `"light"` or `"dark"` | +| `styling.fonts.{monospace,sansSerif,serif,emoji}` | DejaVu, Noto Color Emoji | `{ package; name; }` | +| `styling.fonts.sizes.{applications,terminal,desktop,popups}` | `12`, `12`, `10`, `10` | Font sizes | +| `styling.fonts.packages` | derived, read only | The configured font packages, deduplicated | +| `styling.fonts.provideFontconfig` | `false` | Set `FONTCONFIG_FILE` so the wrapper carries its own fonts | +| `styling.opacity.{applications,terminal,desktop,popups}` | `1.0` | Opacity, from `0.0` to `1.0` | +| `styling.cursor` | Adwaita, size 24 | `{ package; name; size; }` | + +`styling.enable` turns itself on as soon as a scheme is set, so one definition +themes everything. Wrappers that should keep their own look opt out +individually: + +```nix +(wrappers.wrapperModules.foot.apply { + inherit pkgs; + styling.scheme = "gruvbox-dark-hard"; + styling.enable = false; +}).wrapper +``` + +With no scheme set anywhere, `styling.enable` is false and no wrapper changes +behaviour. Enabling it without a scheme is fine too — the palette falls back to +`default-dark`, which is useful when you only care about fonts. + +### Helpers + +`wlib.style` converts a stored colour into whatever shape a program wants: + +| Function | Result | +|---|---| +| `withHash "1d2021"` | `"#1d2021"` | +| `toRGB "1d2021"` | `{ r = 29; g = 32; b = 33; }` | +| `rgbCss "1d2021"` | `"rgb(29, 32, 33)"` | +| `rgbaCss "1d2021" 0.9` | `"rgba(29, 32, 33, 0.9)"` | +| `withAlpha "1d2021" 0.9` | `"1d2021e6"` | +| `formatNumber 0.9` | `"0.9"` (`toString` would give `"0.900000"`) | +| `luminance "1d2021"` | approximate brightness, `0.0` to `1.0` | +| `isDark "1d2021"` | `true` | +| `mkDefaults` | lowers every leaf of an attrset to `lib.mkDefault` | + +It also exposes `parseScheme`, `resolveScheme` and `slots` for working with +base16 schemes directly. Scheme loading is pure — a small line based parser +rather than a YAML tool — so `nix flake check` never needs import from +derivation. The parser reads the canonical base16 layout, which is what all +303 schemes in `pkgs.base16-schemes` use; a file spelling a slot as `base0a` +is reported as missing `base0A`. + +### Adding styling support to a module + +A module opts in by deriving settings from `styling`, gated on +`styling.enable`: + +```nix +{ config, lib, wlib, ... }: +{ + config.settings = lib.mkIf config.styling.enable ( + let + inherit (config) styling; + inherit (wlib.style) withHash; + in + wlib.style.mkDefaults { + font.normal.family = styling.fonts.monospace.name; + font.size = styling.fonts.sizes.terminal; + + colors.primary.background = withHash styling.colors.background; + colors.primary.foreground = withHash styling.colors.foreground; + colors.normal.red = withHash styling.colors.ansi.red; + + window.opacity = styling.opacity.terminal; + } + ); +} +``` + +Two rules: + +- **Gate on `config.styling.enable`.** It is false until a scheme is set, which + is what keeps styling from changing the behaviour of wrappers that do not + want it. +- **Every derived value must be a default**, via `wlib.style.mkDefaults` or an + explicit `lib.mkDefault`. A `settings` option is declared with `default = { }`, + which is priority 1500, so a plain `config.settings.x = …` from styling lands + at priority 100 and would silently outrank the user's own value. Defining it + as a default puts the user back on top. + +Fonts are found through fontconfig, not `PATH`, so there is no point adding +`styling.fonts.*.package` to `extraPackages`. A module only needs the font's +`name`; users who want a self-contained wrapper turn on +`styling.fonts.provideFontconfig`. + +Styling is usually worth keeping in its own file, pulled in with a plain +`imports`, so that a module's own configuration stays readable: + +```nix +# modules//module.nix +{ + imports = [ ./styling.nix ]; + # ... +} +``` + +`modules/alacritty` is the worked example: `styling.nix` derives colours, +fonts and opacity, and `check.nix` shows how to test a styled module by +asserting on the generated config rather than on the built wrapper, which +keeps the check from having to build the program. + ## alternatives - [wrapper-manager](https://github.com/viperML/wrapper-manager) by viperML. This project focuses more on a single module system, configuring wrappers and exporting them. This was an inspiration when building this library, but I wanted to have a more granular approach with a single module per package and a collection of community made modules. diff --git a/checks/styling-apply.nix b/checks/styling-apply.nix new file mode 100644 index 00000000..ae2305f7 --- /dev/null +++ b/checks/styling-apply.nix @@ -0,0 +1,65 @@ +{ + pkgs, + self, +}: + +let + inherit (pkgs) lib; + + # One theme, defined once, mapped over several wrapper modules. This is the + # whole point of applyStyle: wrapper modules are evaluated independently, so + # without it a shared theme has to be repeated at every call site. + themed = self.lib.applyStyle { + inherit pkgs; + styling.scheme = "gruvbox-dark-hard"; + styling.fonts.monospace.name = "JetBrains Mono"; + styling.opacity.terminal = 0.9; + } { inherit (self.wrapperModules) alacritty kitty btop; }; + + # The results are ordinary configs, so a single program can still be refined + # afterwards without losing the shared theme. + refined = themed.alacritty.apply { styling.palette.base00 = "000000"; }; + + names = lib.attrNames themed; + + differing = lib.filter (name: themed.${name}.styling.colors.background != "1d2021") names; + + # A wrapper built without any styling must be unchanged by all of this. + before = (self.wrapperModules.btop.apply { inherit pkgs; }).wrapper; +in +pkgs.runCommand "styling-apply-test" { } '' + fail=0 + expect() { + if [[ "$2" != "$3" ]]; then + echo "FAIL: $1: expected '$3', got '$2'" + fail=1 + fi + } + + echo "Testing applyStyle..." + + expect "applies to every module" '${builtins.toJSON names}' '${ + builtins.toJSON [ + "alacritty" + "btop" + "kitty" + ] + }' + expect "shares one palette" '${builtins.toJSON differing}' '[]' + expect "shares fonts" '${themed.kitty.styling.fonts.monospace.name}' 'JetBrains Mono' + expect "shares opacity" '${toString themed.alacritty.styling.opacity.terminal}' '0.900000' + expect "enabled everywhere" '${lib.boolToString themed.btop.styling.enable}' 'true' + + # Refining one program keeps the shared theme underneath + expect "refined overrides" '${refined.styling.palette.base00}' '000000' + expect "refined keeps the rest" '${refined.styling.palette.base05}' 'd5c4a1' + expect "refined keeps the package" '${refined.package.pname}' 'alacritty' + + # Styling changes nothing until a module opts into consuming it, so an + # unstyled wrapper still builds exactly as before + test -x '${before}/bin/btop' || { echo "FAIL: unstyled wrapper is broken"; fail=1; } + + [[ $fail -eq 0 ]] || exit 1 + echo "SUCCESS: applyStyle test passed" + touch $out +'' diff --git a/checks/styling-colors.nix b/checks/styling-colors.nix new file mode 100644 index 00000000..636b078d --- /dev/null +++ b/checks/styling-colors.nix @@ -0,0 +1,139 @@ +{ + pkgs, + self, +}: + +let + inherit (pkgs) lib; + inherit (self.lib) style; + + # A palette where every slot holds a distinct, recognisable value, so that an + # alias pointing at the wrong slot cannot pass unnoticed. + probe = lib.listToAttrs ( + lib.imap0 ( + index: slot: + lib.nameValuePair slot "0000${lib.fixedWidthString 2 "0" (lib.toLower (lib.toHexString index))}" + ) style.slots + ); + + cfg = + (self.lib.wrapModule ( + { config, ... }: + { + config.package = config.pkgs.hello; + } + )).apply + { + inherit pkgs; + styling.palette = probe; + }; + + colors = cfg.styling.colors; + + expected = { + background = "base00"; + backgroundAlt = "base01"; + selection = "base02"; + comment = "base03"; + foregroundAlt = "base04"; + foreground = "base05"; + foregroundBright = "base06"; + backgroundBright = "base07"; + red = "base08"; + orange = "base09"; + yellow = "base0A"; + green = "base0B"; + cyan = "base0C"; + blue = "base0D"; + magenta = "base0E"; + brown = "base0F"; + accent = "base0D"; + error = "base08"; + warning = "base0A"; + success = "base0B"; + info = "base0C"; + }; + + expectedAnsi = { + black = "base00"; + red = "base08"; + green = "base0B"; + yellow = "base0A"; + blue = "base0D"; + magenta = "base0E"; + cyan = "base0C"; + white = "base05"; + brightBlack = "base03"; + brightRed = "base08"; + brightGreen = "base0B"; + brightYellow = "base0A"; + brightBlue = "base0D"; + brightMagenta = "base0E"; + brightCyan = "base0C"; + brightWhite = "base07"; + }; + + # Each alias must resolve to the value sitting in the slot it claims. + wrongAliases = lib.attrNames ( + lib.filterAttrs (alias: slot: colors.${alias} != probe.${slot}) expected + ); + + wrongAnsi = lib.attrNames ( + lib.filterAttrs (alias: slot: colors.ansi.${alias} != probe.${slot}) expectedAnsi + ); +in +pkgs.runCommand "styling-colors-test" { } '' + fail=0 + expect() { + if [[ "$2" != "$3" ]]; then + echo "FAIL: $1: expected '$3', got '$2'" + fail=1 + fi + } + + echo "Testing styling colour aliases and helpers..." + + expect "semantic aliases" '${builtins.toJSON wrongAliases}' '[]' + expect "ansi aliases" '${builtins.toJSON wrongAnsi}' '[]' + expect "alias count" '${toString (builtins.length (builtins.attrNames expected))}' '21' + expect "ansi count" '${toString (builtins.length (builtins.attrNames colors.ansi))}' '16' + + # Colours are stored bare, so that each consumer can pick its own form + expect "stored bare" '${colors.background}' '000000' + + expect "withHash" '${style.withHash "1d2021"}' '#1d2021' + expect "rgbCss" '${style.rgbCss "1d2021"}' 'rgb(29, 32, 33)' + expect "rgbaCss" '${style.rgbaCss "1d2021" 0.9}' 'rgba(29, 32, 33, 0.9)' + expect "rgbaCss opaque" '${style.rgbaCss "1d2021" 1.0}' 'rgba(29, 32, 33, 1)' + + expect "toRGB" '${builtins.toJSON (style.toRGB "1d2021")}' '${ + builtins.toJSON { + r = 29; + g = 32; + b = 33; + } + }' + expect "toRGB white" '${builtins.toJSON (style.toRGB "ffffff")}' '${ + builtins.toJSON { + r = 255; + g = 255; + b = 255; + } + }' + + expect "withAlpha" '${style.withAlpha "1d2021" 0.9}' '1d2021e6' + expect "withAlpha opaque" '${style.withAlpha "1d2021" 1.0}' '1d2021ff' + expect "withAlpha transparent" '${style.withAlpha "1d2021" 0.0}' '1d202100' + # A small alpha still has to produce two hex digits + expect "withAlpha pads" '${style.withAlpha "1d2021" 0.02}' '1d202105' + + expect "formatNumber" '${style.formatNumber 0.9}' '0.9' + expect "formatNumber whole" '${style.formatNumber 1.0}' '1' + + expect "isDark dark" '${lib.boolToString (style.isDark "1d2021")}' 'true' + expect "isDark light" '${lib.boolToString (style.isDark "fbf1c7")}' 'false' + + [[ $fail -eq 0 ]] || exit 1 + echo "SUCCESS: styling colours test passed" + touch $out +'' diff --git a/checks/styling-enable.nix b/checks/styling-enable.nix new file mode 100644 index 00000000..415b3a43 --- /dev/null +++ b/checks/styling-enable.nix @@ -0,0 +1,106 @@ +{ + pkgs, + self, +}: + +let + inherit (pkgs) lib; + + # Stands in for a wrapper module that has grown styling support, following + # the pattern documented in the README: gate on styling.enable, and define + # every derived value as a default so the user still wins. + themed = self.lib.wrapModule ( + { + config, + lib, + wlib, + ... + }: + { + options.settings = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + }; + + config.package = config.pkgs.hello; + config.settings = lib.mkIf config.styling.enable ( + wlib.style.mkDefaults { + background = wlib.style.withHash config.styling.colors.background; + foreground = wlib.style.withHash config.styling.colors.foreground; + } + ); + } + ); + + unstyled = themed.apply { inherit pkgs; }; + scheme = themed.apply { + inherit pkgs; + styling.scheme = "gruvbox-dark-hard"; + }; + optedOut = themed.apply { + inherit pkgs; + styling.scheme = "gruvbox-dark-hard"; + styling.enable = false; + }; + userOverride = themed.apply { + inherit pkgs; + styling.scheme = "gruvbox-dark-hard"; + settings.background = "#000000"; + }; + fontsOnly = themed.apply { + inherit pkgs; + styling.enable = true; + }; + fontconfig = themed.apply { + inherit pkgs; + styling.scheme = "gruvbox-dark-hard"; + styling.fonts.provideFontconfig = true; + }; +in +pkgs.runCommand "styling-enable-test" { } '' + fail=0 + expect() { + if [[ "$2" != "$3" ]]; then + echo "FAIL: $1: expected '$3', got '$2'" + fail=1 + fi + } + + echo "Testing styling enablement and priority..." + + # Without a scheme, styling is inert: existing wrappers are unaffected + expect "off by default" '${lib.boolToString unstyled.styling.enable}' 'false' + expect "no settings when off" '${builtins.toJSON unstyled.settings}' '{}' + + # Setting a scheme turns styling on everywhere, without a per-module opt-in + expect "on with a scheme" '${lib.boolToString scheme.styling.enable}' 'true' + expect "styles background" '${scheme.settings.background}' '#1d2021' + expect "styles foreground" '${scheme.settings.foreground}' '#d5c4a1' + + # A single wrapper can opt out again + expect "opt out" '${lib.boolToString optedOut.styling.enable}' 'false' + expect "no settings when opted out" '${builtins.toJSON optedOut.settings}' '{}' + + # THE contract every styled module depends on: styling defines defaults, so + # anything the user set explicitly wins. + expect "user beats styling" '${userOverride.settings.background}' '#000000' + expect "user override is narrow" '${userOverride.settings.foreground}' '#d5c4a1' + + # Enabling styling without a scheme still yields a usable palette rather + # than an error, so fonts alone can be themed + expect "fallback palette" '${fontsOnly.settings.background}' '#181818' + expect "fallback polarity" '${fontsOnly.styling.polarity}' 'dark' + + # Fonts are not wired into the environment unless asked for + expect "no fontconfig by default" '${lib.boolToString (scheme.env ? FONTCONFIG_FILE)}' 'false' + expect "fontconfig when asked" '${lib.boolToString (fontconfig.env ? FONTCONFIG_FILE)}' 'true' + + if ! grep -q '${pkgs.dejavu_fonts}' '${fontconfig.env.FONTCONFIG_FILE or "/dev/null"}'; then + echo "FAIL: generated fontconfig should reference the configured font packages" + fail=1 + fi + + [[ $fail -eq 0 ]] || exit 1 + echo "SUCCESS: styling enable test passed" + touch $out +'' diff --git a/checks/styling-scheme.nix b/checks/styling-scheme.nix new file mode 100644 index 00000000..8167e7c5 --- /dev/null +++ b/checks/styling-scheme.nix @@ -0,0 +1,118 @@ +{ + pkgs, + self, +}: + +let + inherit (pkgs) lib; + + styled = + styling: + (self.lib.wrapModule ( + { config, ... }: + { + config.package = config.pkgs.hello; + } + )).apply + { inherit pkgs styling; }; + + # A scheme held in a derivation, with no `variant` to read. + inlineFile = pkgs.writeText "inline-scheme.yaml" '' + name: "Inline" + palette: + base00: "#eeeeee" + base01: "#222222" + base02: "#333333" + base03: "#444444" + base04: "#555555" + base05: "#666666" + base06: "#777777" + base07: "#888888" + base08: "#999999" + base09: "#aaaaaa" + base0A: "#bbbbbb" + base0B: "#cccccc" + base0C: "#dddddd" + base0D: "#eeeeee" + base0E: "#ffffff" + base0F: "#000000" + ''; + + byName = styled { scheme = "gruvbox-dark-hard"; }; + byDerivation = styled { scheme = inlineFile; }; + byPathString = styled { scheme = "${pkgs.base16-schemes}/share/themes/default-light.yaml"; }; + + overridden = styled { + scheme = "gruvbox-dark-hard"; + palette.base0D = "abcdef"; + }; + + incomplete = styled { + scheme = pkgs.writeText "incomplete-scheme.yaml" '' + palette: + base00: "#111111" + ''; + }; + missingSlots = builtins.tryEval incomplete.styling.palette.base00; + + parsedGruvbox = self.lib.style.parseScheme ( + builtins.readFile "${pkgs.base16-schemes}/share/themes/gruvbox-dark-hard.yaml" + ); + + # Every scheme shipped by nixpkgs must parse into a complete palette. This is + # what justifies the hand written parser over importing from a derivation. + themeDir = "${pkgs.base16-schemes}/share/themes"; + allSchemes = builtins.attrNames (builtins.readDir themeDir); + unparsable = lib.filter ( + file: + let + palette = (self.lib.style.parseScheme (builtins.readFile "${themeDir}/${file}")).palette; + in + lib.any (slot: !(palette ? ${slot})) self.lib.style.slots + ) allSchemes; +in +pkgs.runCommand "styling-scheme-test" { } '' + fail=0 + expect() { + if [[ "$2" != "$3" ]]; then + echo "FAIL: $1: expected '$3', got '$2'" + fail=1 + fi + } + + echo "Testing styling scheme resolution..." + + # By name, from pkgs.base16-schemes + expect "by name base00" '${byName.styling.palette.base00}' '1d2021' + expect "by name base0A" '${byName.styling.palette.base0A}' 'fabd2f' + expect "by name polarity" '${byName.styling.polarity}' 'dark' + + # Scheme metadata is picked up alongside the palette + expect "parsed variant" '${parsedGruvbox.variant}' 'dark' + + # A derivation holding a scheme + expect "by derivation base00" '${byDerivation.styling.palette.base00}' 'eeeeee' + expect "by derivation base0A" '${byDerivation.styling.palette.base0A}' 'bbbbbb' + + # A store path passed as a string + expect "by path string base00" '${byPathString.styling.palette.base00}' 'f8f8f8' + expect "by path string polarity" '${byPathString.styling.polarity}' 'light' + + # No variant to read, so polarity falls back to the brightness of base00 + expect "by derivation polarity" '${byDerivation.styling.polarity}' 'light' + + # An explicit palette entry outranks the scheme + expect "override base0D" '${overridden.styling.palette.base0D}' 'abcdef' + expect "override leaves others" '${overridden.styling.palette.base00}' '1d2021' + + # An incomplete scheme is rejected rather than silently yielding gaps + expect "incomplete scheme fails" '${lib.boolToString missingSlots.success}' 'false' + + # The parser must handle every scheme nixpkgs ships + expect "all ${toString (builtins.length allSchemes)} schemes parse" \ + '${builtins.toJSON unparsable}' '[]' + + [[ $fail -eq 0 ]] || exit 1 + echo "SUCCESS: styling scheme test passed" + touch $out +'' diff --git a/lib/default.nix b/lib/default.nix index 4eaf84b3..5f56750f 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -253,9 +253,52 @@ let inherit modules class specialArgs; }; - modules = lib.genAttrs [ "package" "flags" "command" "wrapper" "meta" "systemd" ] ( - name: import ./modules/${name}.nix - ); + modules = lib.genAttrs [ + "package" + "flags" + "command" + "wrapper" + "meta" + "styling" + "systemd" + ] (name: import ./modules/${name}.nix); + + /** + Colour scheme loading and colour formatting helpers, used by the styling + module and by any wrapper module that derives configuration from it. + */ + style = + (import ./style/base16.nix { inherit lib; }) // (import ./style/colors.nix { inherit lib; }); + + /** + Apply the same settings to a set of wrapper modules at once. + + Wrapper modules are evaluated independently, so there is no shared + configuration for a theme to live in. This maps one settings module over a + whole set of them, which is how a single `styling` definition reaches + every program. + + # Type + ``` + applyStyle :: Module -> AttrsOf WrapperModule -> AttrsOf Config + ``` + + # Example + + ```nix + themed = wlib.applyStyle { + inherit pkgs; + styling.scheme = "gruvbox-dark-hard"; + } { inherit (wrappers.wrapperModules) alacritty foot rofi; }; + + # themed.alacritty.wrapper, themed.foot.wrapper, ... + ``` + + The results are ordinary configs, so they can be refined further per + program with their own `apply`. + */ + applyStyle = + settings: wrapperModules: lib.mapAttrs (_: module: module.apply settings) wrapperModules; /** Create a wrapper configuration using the NixOS module system. @@ -365,6 +408,7 @@ let ) modules.wrapper modules.meta + modules.styling wrapperModule ]; }; @@ -694,8 +738,10 @@ let inherit types modules + style wrapModule wrapPackage + applyStyle escapeShellArgWithEnv generateArgsFromFlags flagToArgs diff --git a/lib/modules/styling.nix b/lib/modules/styling.nix new file mode 100644 index 00000000..627949ee --- /dev/null +++ b/lib/modules/styling.nix @@ -0,0 +1,266 @@ +{ + config, + lib, + wlib, + ... +}: +let + cfg = config.styling; + + # Used when styling is enabled without a scheme, so that reading a colour + # never throws. Programs styled with this alone still get a coherent theme. + fallbackScheme = "default-dark"; + + scheme = wlib.style.resolveScheme config.pkgs ( + if cfg.scheme == null then fallbackScheme else cfg.scheme + ); + + /** + Semantic names for the base16 slots, following the base16 styling + guidelines. Modules should prefer these over raw slot numbers: reading + `colors.background` says what a value means, `palette.base00` does not. + */ + aliases = { + background = "base00"; + backgroundAlt = "base01"; + selection = "base02"; + comment = "base03"; + foregroundAlt = "base04"; + foreground = "base05"; + foregroundBright = "base06"; + backgroundBright = "base07"; + + red = "base08"; + orange = "base09"; + yellow = "base0A"; + green = "base0B"; + cyan = "base0C"; + blue = "base0D"; + magenta = "base0E"; + brown = "base0F"; + + accent = "base0D"; + error = "base08"; + warning = "base0A"; + success = "base0B"; + info = "base0C"; + }; + + /** + The standard base16 mapping onto the 16 ANSI terminal colours. base16 has + no distinct bright variants for the six hues, so those repeat; only the + greys differ. Terminals are the main consumer, and deriving this once here + keeps them from each inventing their own mapping. + */ + ansiAliases = { + black = "base00"; + red = "base08"; + green = "base0B"; + yellow = "base0A"; + blue = "base0D"; + magenta = "base0E"; + cyan = "base0C"; + white = "base05"; + + brightBlack = "base03"; + brightRed = "base08"; + brightGreen = "base0B"; + brightYellow = "base0A"; + brightBlue = "base0D"; + brightMagenta = "base0E"; + brightCyan = "base0C"; + brightWhite = "base07"; + }; + + mkFont = attr: fontName: { + package = lib.mkOption { + type = lib.types.package; + default = config.pkgs.${attr}; + defaultText = lib.literalExpression "config.pkgs.${attr}"; + description = "Package providing the font."; + }; + name = lib.mkOption { + type = lib.types.str; + default = fontName; + description = "Family name of the font, as fontconfig reports it."; + }; + }; + + mkSize = + default: what: + lib.mkOption { + type = lib.types.numbers.positive; + inherit default; + description = "Font size to use for ${what}."; + }; + + mkOpacity = + what: + lib.mkOption { + type = lib.types.numbers.between 0.0 1.0; + default = 1.0; + description = "Opacity to use for ${what}, from 0.0 (transparent) to 1.0 (opaque)."; + }; + + fontRoles = [ + "monospace" + "sansSerif" + "serif" + "emoji" + ]; +in +{ + _file = "lib/modules/styling.nix"; + + options.styling = { + enable = lib.mkOption { + type = lib.types.bool; + default = cfg.scheme != null; + defaultText = lib.literalExpression "config.styling.scheme != null"; + description = '' + Whether this wrapper should style itself from `styling`. + + Defaults to true as soon as a scheme is set, so that setting one + theme applies it everywhere. Set it to false on an individual wrapper + to leave that program's own configuration alone. + + Modules that support styling must gate their generated settings on + this option, and must define them with `lib.mkDefault` so that + anything the user configured explicitly still wins. + ''; + }; + + scheme = lib.mkOption { + type = lib.types.nullOr (lib.types.either lib.types.str lib.types.path); + default = null; + example = "gruvbox-dark-hard"; + description = '' + The base16 colour scheme to derive `styling.palette` from. + + Accepts the name of a scheme in `pkgs.base16-schemes` or a path to a + base16 YAML file. + + Individual colours are set through `styling.palette.`, which + takes precedence over the scheme. + ''; + }; + + palette = lib.genAttrs wlib.style.slots ( + slot: + lib.mkOption { + type = lib.types.str; + description = "The base16 `${slot}` colour, as lower case 6 digit hex without a `#` prefix."; + } + ); + + colors = lib.mkOption { + type = lib.types.attrsOf (lib.types.either lib.types.str (lib.types.attrsOf lib.types.str)); + readOnly = true; + default = lib.mapAttrs (_: slot: cfg.palette.${slot}) aliases // { + ansi = lib.mapAttrs (_: slot: cfg.palette.${slot}) ansiAliases; + }; + defaultText = lib.literalMD "semantic aliases for `styling.palette`, plus `colors.ansi`"; + description = '' + The palette under semantic names, e.g. `colors.background` for + `palette.base00`, plus `colors.ansi` holding the standard base16 + mapping onto the 16 ANSI terminal colours. + ''; + }; + + polarity = lib.mkOption { + type = lib.types.enum [ + "light" + "dark" + ]; + default = + if scheme.variant == "light" || scheme.variant == "dark" then + scheme.variant + else if wlib.style.isDark cfg.palette.base00 then + "dark" + else + "light"; + defaultText = lib.literalMD "the scheme's `variant`, or derived from the brightness of `base00`"; + description = '' + Whether the theme is light or dark. Programs that ship separate light + and dark variants can select between them with this. + ''; + }; + + fonts = { + monospace = mkFont "dejavu_fonts" "DejaVu Sans Mono"; + sansSerif = mkFont "dejavu_fonts" "DejaVu Sans"; + serif = mkFont "dejavu_fonts" "DejaVu Serif"; + emoji = mkFont "noto-fonts-color-emoji" "Noto Color Emoji"; + + sizes = { + applications = mkSize 12 "regular application windows"; + terminal = mkSize 12 "terminal emulators"; + desktop = mkSize 10 "bars, docks and other desktop chrome"; + popups = mkSize 10 "notifications, launchers and other popups"; + }; + + packages = lib.mkOption { + type = lib.types.listOf lib.types.package; + readOnly = true; + default = lib.unique (map (role: cfg.fonts.${role}.package) fontRoles); + defaultText = lib.literalMD "the packages of the configured fonts, deduplicated"; + description = "The packages providing the configured fonts, deduplicated."; + }; + + provideFontconfig = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to point the wrapped program at a generated fontconfig + configuration covering `styling.fonts.packages`, via + `FONTCONFIG_FILE`. + + Off by default: a wrapper normally wants to see the fonts installed + on the system it runs on. Turn this on when a wrapper has to be + self-contained. + ''; + }; + }; + + opacity = { + applications = mkOpacity "regular application windows"; + terminal = mkOpacity "terminal emulators"; + desktop = mkOpacity "bars, docks and other desktop chrome"; + popups = mkOpacity "notifications, launchers and other popups"; + }; + + cursor = { + package = lib.mkOption { + type = lib.types.package; + default = config.pkgs.adwaita-icon-theme; + defaultText = lib.literalExpression "config.pkgs.adwaita-icon-theme"; + description = "Package providing the cursor theme."; + }; + name = lib.mkOption { + type = lib.types.str; + default = "Adwaita"; + description = "Name of the cursor theme."; + }; + size = lib.mkOption { + type = lib.types.ints.positive; + default = 24; + description = "Cursor size in pixels."; + }; + }; + }; + + # The scheme seeds the palette at the same priority as an option default, so + # that setting styling.palette. directly overrides it. + # + # The keys come from the static slot list rather than from the parsed scheme: + # the module system forces the key set of every definition while checking for + # unmatched definitions, which would resolve the scheme (and so read + # config.pkgs) even for wrappers that never look at a colour. + config.styling.palette = lib.genAttrs wlib.style.slots ( + slot: lib.mkOptionDefault scheme.palette.${slot} + ); + + config.env.FONTCONFIG_FILE = lib.mkIf (cfg.enable && cfg.fonts.provideFontconfig) ( + lib.mkDefault "${config.pkgs.makeFontsConf { fontDirectories = cfg.fonts.packages; }}" + ); +} diff --git a/lib/style/base16.nix b/lib/style/base16.nix new file mode 100644 index 00000000..bafcfe86 --- /dev/null +++ b/lib/style/base16.nix @@ -0,0 +1,97 @@ +{ lib }: +let + slots = map (char: "base0${char}") (lib.stringToCharacters "0123456789ABCDEF"); + + # Matches `base0A: "#fabd2f" # yellow`, with the quotes, the `#` prefix and + # the trailing comment all optional. + # + # ponytail: canonical slot spelling only. All 303 schemes in + # `pkgs.base16-schemes` write `base0A`, never `base0a`; a file that does not + # is reported as missing that slot. Lower case the match if that ever shows + # up in the wild. + colorLine = ''[[:space:]]*(base[0-9A-F]{2})[[:space:]]*:[[:space:]]*"?#?([0-9a-fA-F]{6})"?.*''; + + # Matches a top level `variant: "dark"`, tolerating quoting and a trailing comment. + variantLine = ''[[:space:]]*variant[[:space:]]*:[[:space:]]*"?([^"#]*[^" #])"?.*''; + + /** + Parse a base16 scheme from the text of a YAML file. + + This is a deliberately small line based parser rather than a real YAML + parser: it only understands the shape base16 schemes actually take, which + keeps scheme loading pure. Using a YAML tool would require import from + derivation, and CI runs `nix flake check`. + + # Type + + ``` + parseScheme :: String -> { palette :: AttrsOf String, variant :: Null | String } + ``` + */ + parseScheme = + text: + let + lines = lib.splitString "\n" text; + in + { + palette = lib.listToAttrs ( + lib.concatMap ( + line: + let + matched = builtins.match colorLine line; + in + lib.optional (matched != null) ( + lib.nameValuePair (lib.head matched) (lib.toLower (lib.elemAt matched 1)) + ) + ) lines + ); + + variant = lib.foldl' ( + acc: line: + let + matched = builtins.match variantLine line; + in + if matched == null then acc else lib.head matched + ) null lines; + }; + + /** + Resolve the value of `styling.scheme` into a parsed scheme. + + Accepts a path or derivation holding a base16 YAML file, a string + containing `/`, treated as a path to one, or any other string, treated as + a scheme name in `pkgs.base16-schemes`, e.g. `"gruvbox-dark-hard"`. + + Individual colours are set through `styling.palette.`, which + overrides the scheme, so there is no attrset form here. + + # Type + + ``` + resolveScheme :: Pkgs -> (Path | Derivation | String) -> { palette; variant; } + ``` + */ + resolveScheme = + pkgs: scheme: + let + file = + if lib.isPath scheme || lib.isDerivation scheme || lib.hasInfix "/" scheme then + scheme + else + "${pkgs.base16-schemes}/share/themes/${scheme}.yaml"; + + parsed = parseScheme (builtins.readFile file); + missing = lib.filter (slot: !(parsed.palette ? ${slot})) slots; + in + if missing == [ ] then + parsed + else + throw "styling: scheme ${toString scheme} is missing base16 colours: ${lib.concatStringsSep ", " missing}"; +in +{ + inherit + slots + parseScheme + resolveScheme + ; +} diff --git a/lib/style/colors.nix b/lib/style/colors.nix new file mode 100644 index 00000000..21d825c2 --- /dev/null +++ b/lib/style/colors.nix @@ -0,0 +1,174 @@ +{ lib }: +let + /** + Colours are stored as lower case 6 digit hex without a prefix, because + that is the one form every config format can be derived from. These + helpers convert a stored colour into whatever shape a given program wants. + */ + + channel = color: offset: lib.fromHexString (builtins.substring offset 2 color); + + # lib.toHexString drops the leading zero, but a hex byte always needs two digits. + toHexByte = + n: + let + hex = lib.toLower (lib.toHexString n); + in + if builtins.stringLength hex < 2 then "0${hex}" else hex; + + # Clamp to [0, 1] and scale to a byte, rounding to nearest. + toByte = fraction: builtins.floor (lib.min 1.0 (lib.max 0.0 (fraction * 1.0)) * 255 + 0.5); + + # `toString 0.9` yields "0.900000". Trim the padding so that generated CSS + # reads the way someone would have written it by hand. + trimFloat = + value: + let + string = toString value; + whole = builtins.match "(-?[0-9]+)\\.0*" string; + fraction = builtins.match "(-?[0-9]+\\.[0-9]*[1-9])0*" string; + in + if whole != null then + builtins.head whole + else if fraction != null then + builtins.head fraction + else + string; +in +rec { + /** + Prefix a colour with `#`, the form most config formats want. + + # Example + + ```nix + withHash "1d2021" + => "#1d2021" + ``` + */ + withHash = color: "#${color}"; + + /** + Split a colour into its integer red, green and blue channels. + + # Example + + ```nix + toRGB "1d2021" + => { r = 29; g = 32; b = 33; } + ``` + */ + toRGB = color: { + r = channel color 0; + g = channel color 2; + b = channel color 4; + }; + + /** + Render a colour as a CSS `rgb()` function, for the GTK stylesheets used by + waybar, swaync, swayosd and anyrun. + + # Example + + ```nix + rgbCss "1d2021" + => "rgb(29, 32, 33)" + ``` + */ + rgbCss = + color: + let + rgb = toRGB color; + in + "rgb(${toString rgb.r}, ${toString rgb.g}, ${toString rgb.b})"; + + /** + Render a colour as a CSS `rgba()` function with the given alpha. + + # Example + + ```nix + rgbaCss "1d2021" 0.9 + => "rgba(29, 32, 33, 0.9)" + ``` + */ + rgbaCss = + color: alpha: + let + rgb = toRGB color; + in + "rgba(${toString rgb.r}, ${toString rgb.g}, ${toString rgb.b}, ${trimFloat alpha})"; + + /** + Append an alpha channel as a hex byte, the form hyprland, kitty and several + terminals use for translucency. + + # Example + + ```nix + withAlpha "1d2021" 0.9 + => "1d2021e6" + ``` + */ + withAlpha = color: alpha: "${color}${toHexByte (toByte alpha)}"; + + /** + Render a number without the padding `toString` adds to floats, for config + formats that take a bare float such as an opacity. + + # Example + + ```nix + formatNumber 0.9 + => "0.9" # toString would give "0.900000" + ``` + */ + formatNumber = trimFloat; + + /** + Approximate relative brightness of a colour, in `[0, 1]`. + + This weights the gamma encoded channels rather than linearising them + first, which Nix cannot do without a `pow` builtin. That is accurate + enough for deciding whether a scheme is light or dark, which is all it is + used for here. + + # Example + + ```nix + luminance "1d2021" + => 0.12... + ``` + */ + luminance = + color: + let + rgb = toRGB color; + in + (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255; + + /** + Whether a colour reads as dark. Used to derive `styling.polarity` for + schemes that do not declare a `variant`. + */ + isDark = color: luminance color < 0.5; + + /** + Lower every leaf of an attrset to `lib.mkDefault`. + + Styling has to yield to anything the user set themselves, so every value a + module derives from `styling` must be a default. This is sugar for the + common case of a whole generated settings block. + + # Example + + ```nix + config.settings = lib.mkIf config.styling.enable ( + wlib.style.mkDefaults { + colors.primary.background = wlib.style.withHash c.background; + } + ); + ``` + */ + mkDefaults = lib.mapAttrsRecursive (_: lib.mkDefault); +} diff --git a/modules/alacritty/check.nix b/modules/alacritty/check.nix new file mode 100644 index 00000000..76cca3a6 --- /dev/null +++ b/modules/alacritty/check.nix @@ -0,0 +1,106 @@ +{ + pkgs, + self, +}: + +let + inherit (pkgs) lib; + + plain = self.wrapperModules.alacritty.apply { + inherit pkgs; + settings.window.dynamic_title = false; + }; + + styled = self.wrapperModules.alacritty.apply { + inherit pkgs; + styling = { + scheme = "gruvbox-dark-hard"; + fonts.monospace.name = "JetBrains Mono"; + fonts.sizes.terminal = 14; + opacity.terminal = 0.9; + }; + }; + + # Styling yields to anything set explicitly, down to a single colour. + overridden = styled.apply { + settings.colors.primary.background = "#000000"; + settings.font.size = 20; + }; + + optedOut = self.wrapperModules.alacritty.apply { + inherit pkgs; + styling.scheme = "gruvbox-dark-hard"; + styling.enable = false; + }; + + # Asserting on the generated config rather than the built wrapper keeps this + # check from pulling in alacritty itself. + configOf = wrapper: wrapper."alacritty.toml".path; +in +pkgs.runCommand "alacritty-test" { } '' + fail=0 + + plain='${configOf plain}' + styled='${configOf styled}' + overridden='${configOf overridden}' + optedOut='${configOf optedOut}' + + has() { + if ! grep -qF "$2" "$1"; then + echo "FAIL: $3" + echo "--- $1" + cat "$1" + fail=1 + fi + } + hasNot() { + if grep -qF "$2" "$1"; then + echo "FAIL: $3" + echo "--- $1" + cat "$1" + fail=1 + fi + } + + echo "Testing alacritty configuration..." + + has "$plain" 'dynamic_title = false' "plain settings should reach the config" + hasNot "$plain" '[colors' "an unstyled config should carry no colours" + + echo "Testing alacritty styling..." + + # Colours, from the gruvbox-dark-hard palette + has "$styled" 'background = "#1d2021"' "primary background should be base00" + has "$styled" 'foreground = "#d5c4a1"' "primary foreground should be base05" + has "$styled" 'bright_foreground = "#fbf1c7"' "bright foreground should be base07" + + # normal and bright are the 16 ANSI colours; base16 has no separate bright + # hues, so only the greys differ between the two blocks + has "$styled" '[colors.normal]' "there should be a normal colour block" + has "$styled" '[colors.bright]' "there should be a bright colour block" + has "$styled" 'red = "#fb4934"' "ansi red should be base08" + has "$styled" 'black = "#1d2021"' "normal black should be base00" + has "$styled" 'black = "#665c54"' "bright black should be base03" + + # Selection and cursor + has "$styled" 'text = "#d5c4a1"' "selection text should be base05" + has "$styled" 'cursor = "#d5c4a1"' "cursor should be base05" + + # Fonts and opacity + has "$styled" 'family = "JetBrains Mono"' "the monospace font should be used" + has "$styled" 'style = "Regular"' "the font style should be set" + has "$styled" 'size = 14' "the terminal font size should be used" + has "$styled" 'opacity = 0.9' "the terminal opacity should be used" + + # Styling defines defaults, so explicit settings win + has "$overridden" 'background = "#000000"' "an explicit background should win" + has "$overridden" 'size = 20' "an explicit font size should win" + has "$overridden" 'foreground = "#d5c4a1"' "overriding one value should keep the rest" + + # A wrapper can opt out even with a scheme set + hasNot "$optedOut" '[colors' "an opted out config should carry no colours" + + [[ $fail -eq 0 ]] || exit 1 + echo "SUCCESS: alacritty test passed" + touch $out +'' diff --git a/modules/alacritty/module.nix b/modules/alacritty/module.nix index 3ff76780..b5a829ca 100644 --- a/modules/alacritty/module.nix +++ b/modules/alacritty/module.nix @@ -9,6 +9,7 @@ let in { _class = "wrapper"; + imports = [ ./styling.nix ]; options = { settings = lib.mkOption { type = tomlFmt.type; diff --git a/modules/alacritty/styling.nix b/modules/alacritty/styling.nix new file mode 100644 index 00000000..388c3e7e --- /dev/null +++ b/modules/alacritty/styling.nix @@ -0,0 +1,65 @@ +{ + config, + lib, + wlib, + ... +}: +let + inherit (config) styling; + inherit (wlib.style) withHash; + + ansiNames = [ + "black" + "red" + "green" + "yellow" + "blue" + "magenta" + "cyan" + "white" + ]; + + upperFirst = + string: + lib.toUpper (builtins.substring 0 1 string) + + builtins.substring 1 (builtins.stringLength string) string; + + # alacritty's normal and bright blocks are exactly the 16 ANSI colours, which + # styling.colors.ansi already provides in the standard base16 mapping. + ansiBlock = pick: lib.genAttrs ansiNames (name: withHash styling.colors.ansi.${pick name}); +in +{ + _class = "wrapper"; + + config.settings = lib.mkIf styling.enable ( + wlib.style.mkDefaults { + colors = { + primary = { + background = withHash styling.colors.background; + foreground = withHash styling.colors.foreground; + bright_foreground = withHash styling.colors.ansi.brightWhite; + }; + selection = { + background = withHash styling.colors.selection; + text = withHash styling.colors.foreground; + }; + cursor = { + cursor = withHash styling.colors.foreground; + text = withHash styling.colors.background; + }; + normal = ansiBlock (name: name); + bright = ansiBlock (name: "bright${upperFirst name}"); + }; + + font = { + normal = { + family = styling.fonts.monospace.name; + style = "Regular"; + }; + size = styling.fonts.sizes.terminal; + }; + + window.opacity = styling.opacity.terminal; + } + ); +}