From 065838e749973f421eaa54d4be72adac1f23e8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 12 Aug 2026 07:08:42 +0200 Subject: [PATCH 1/3] lib/modules/styling: add a core module for styling Wrapper modules are evaluated independently of each other, so there is no shared configuration a theme can live in. Every theme-adjacent option today is program-local pass-through: rofi takes rasi, yazi and helix take TOML, waybar and swaync take raw CSS, and the four terminals take colour keys in four different formats. Sharing one palette means writing the same hex strings into a dozen incompatible files by hand. Add a styling module, in the spirit of stylix, imported into every wrapper alongside wrapper and meta. It holds one colour scheme, font, opacity and cursor definition that programs derive their own configuration from, and wlib.applyStyle maps a single definition over as many modules as wanted. styling.scheme takes a base16 scheme by name from pkgs.base16-schemes, by path or derivation, or inline. It seeds styling.palette, which stays settable per slot so a scheme is a starting point rather than a cage. styling.colors exposes the palette under semantic names plus the standard base16 mapping onto the 16 ANSI colours, so terminals do not each have to invent their own. Scheme loading is a small line based parser rather than a YAML tool: that keeps it pure, which matters because CI is nix flake check. The parser is tested against all 303 schemes nixpkgs ships. styling.enable turns on as soon as a scheme is set, so one definition themes everything, and can be switched off per wrapper. With no scheme configured it is false and no existing wrapper changes behaviour. The namespace is styling rather than style because swayosd already declares a style option, and theme and themes are taken by rofi, yazi, helix and jjui. An always-imported module cannot collide with any of them. No program module consumes this yet. The README documents the pattern they will follow, and checks/styling-enable.nix pins the priority contract it rests on: derived values must be defaults, or a settings definition at priority 100 silently outranks the user's own value. Assisted-by: Claude Opus 5 --- CHANGELOG.md | 14 ++ README.md | 191 ++++++++++++++++++++++++++ checks/styling-apply.nix | 65 +++++++++ checks/styling-colors.nix | 139 +++++++++++++++++++ checks/styling-enable.nix | 106 +++++++++++++++ checks/styling-scheme.nix | 143 ++++++++++++++++++++ lib/default.nix | 52 +++++++- lib/modules/styling.nix | 272 ++++++++++++++++++++++++++++++++++++++ lib/style/base16.nix | 196 +++++++++++++++++++++++++++ lib/style/colors.nix | 174 ++++++++++++++++++++++++ 10 files changed, 1349 insertions(+), 3 deletions(-) create mode 100644 checks/styling-apply.nix create mode 100644 checks/styling-colors.nix create mode 100644 checks/styling-enable.nix create mode 100644 checks/styling-scheme.nix create mode 100644 lib/modules/styling.nix create mode 100644 lib/style/base16.nix create mode 100644 lib/style/colors.nix diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b67ef..f16d2d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ ### 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`, by path, or inline), `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. +- `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 bde5a53..098145e 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,194 @@ 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`, a +path or derivation holding a base16 YAML file, or an inline attrset: + +```nix +styling.scheme = "gruvbox-dark-hard"; +styling.scheme = ./my-scheme.yaml; +styling.scheme = { base00 = "1d2021"; base01 = "3c3836"; /* ... */ base0F = "d65d0e"; }; +``` + +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, derivation, or attrset | +| `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. + +### 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; + } + ); + + config.extraPackages = lib.mkIf config.styling.enable [ config.styling.fonts.monospace.package ]; +} +``` + +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. + ## 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 0000000..ae2305f --- /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 0000000..f530c58 --- /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.scheme = 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 0000000..415b3a4 --- /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 0000000..00f69e1 --- /dev/null +++ b/checks/styling-scheme.nix @@ -0,0 +1,143 @@ +{ + pkgs, + self, +}: + +let + inherit (pkgs) lib; + + styled = + styling: + (self.lib.wrapModule ( + { config, ... }: + { + config.package = config.pkgs.hello; + } + )).apply + { inherit pkgs styling; }; + + # A scheme in the older flat layout: no `palette:` block, no `#` prefixes. + legacyFile = pkgs.writeText "legacy-scheme.yaml" '' + scheme: "Legacy" + author: "nobody" + base00: 111111 + 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 = legacyFile; }; + byPathString = styled { scheme = "${pkgs.base16-schemes}/share/themes/default-light.yaml"; }; + + # `#` prefixes, upper case values and lower case slot names all normalised. + byAttrs = styled { + scheme = { + 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"; + }; + }; + + overridden = styled { + scheme = "gruvbox-dark-hard"; + palette.base0D = "abcdef"; + }; + + incomplete = styled { + scheme = { + 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 name" '${parsedGruvbox.name}' 'Gruvbox dark, hard' + expect "parsed variant" '${parsedGruvbox.variant}' 'dark' + + # A derivation holding a scheme in the older flat layout + expect "by derivation base00" '${byDerivation.styling.palette.base00}' '111111' + 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' + + # An inline attrset: '#' stripped, values lower cased, slot names canonicalised + expect "by attrs base00" '${byAttrs.styling.palette.base00}' 'eeeeee' + expect "by attrs base0A" '${byAttrs.styling.palette.base0A}' 'bbbbbb' + # No variant to read, so polarity falls back to the brightness of base00 + expect "by attrs polarity" '${byAttrs.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 4eaf84b..5f56750 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 0000000..0e47c81 --- /dev/null +++ b/lib/modules/styling.nix @@ -0,0 +1,272 @@ +{ + 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"; + }; + + mkColorAlias = + slot: + lib.mkOption { + type = lib.types.str; + readOnly = true; + default = cfg.palette.${slot}; + defaultText = lib.literalExpression "config.styling.palette.${slot}"; + description = "Alias for `styling.palette.${slot}`."; + }; + + 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.oneOf [ + lib.types.str + lib.types.path + (lib.types.attrsOf lib.types.str) + ] + ); + 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`, a path to a + base16 YAML file, or an attrset of `base00` to `base0F` colours. + + Individual colours can still be adjusted afterwards by setting + `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.mapAttrs (_: mkColorAlias) aliases // { + ansi = lib.mapAttrs (_: mkColorAlias) ansiAliases; + }; + + 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 0000000..d4d2b77 --- /dev/null +++ b/lib/style/base16.nix @@ -0,0 +1,196 @@ +{ lib }: +let + /** + The canonical base16 slot names. + + The base16 specification spells the hex digit in upper case (`base0A`, not + `base0a`), but scheme files in the wild are inconsistent about it. Parsed + keys are lower-cased and looked up in `bySlug` to normalise them. + */ + slots = [ + "base00" + "base01" + "base02" + "base03" + "base04" + "base05" + "base06" + "base07" + "base08" + "base09" + "base0A" + "base0B" + "base0C" + "base0D" + "base0E" + "base0F" + ]; + + bySlug = lib.listToAttrs (map (slot: lib.nameValuePair (lib.toLower slot) slot) slots); + + # Matches `base0A: "#fabd2f" # yellow` as well as the older flat `base0A: fabd2f`. + colorLine = ''^[[:space:]]*(base[0-9a-fA-F]{2})[[:space:]]*:[[:space:]]*"?#?([0-9a-fA-F]{6})"?.*$''; + + # Matches a top level `variant: "dark"` / `name: "Gruvbox dark, hard"`, + # tolerating both quoting and a trailing comment. + metaLine = key: ''^[[:space:]]*${key}[[:space:]]*:[[:space:]]*"?([^"#]*[^" #])"?.*$''; + + /** + Normalise a single colour to lower case 6 digit hex without a `#` prefix, + which is how palettes are stored throughout the styling module. + */ + normalizeColor = + name: value: + let + matched = builtins.match "#?([0-9a-fA-F]{6})" (toString value); + in + if matched == null then + throw "styling: ${name} is not a 6 digit hex colour, got ${toString value}" + else + lib.toLower (builtins.head matched); + + /** + Normalise an attrset palette, dropping keys that are not base16 slots. + + Unknown keys are ignored rather than rejected so that base24 schemes + (which add `base10`..`base17`) can be passed through unchanged. A typo is + still caught, by `requireSlots` reporting the resulting missing slot. + */ + normalizePalette = + attrs: + lib.listToAttrs ( + lib.concatMap ( + entry: + let + key = lib.toLower entry.name; + in + lib.optional (bySlug ? ${key}) ( + lib.nameValuePair bySlug.${key} (normalizeColor entry.name entry.value) + ) + ) (lib.mapAttrsToList lib.nameValuePair attrs) + ); + + requireSlots = + source: palette: + let + missing = lib.filter (slot: !(palette ? ${slot})) slots; + in + if missing == [ ] then + palette + else + throw "styling: ${source} is missing base16 colours: ${lib.concatStringsSep ", " missing}"; + + /** + 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`. + + Handles both the current tinted-theming layout (a nested `palette:` block + with `#` prefixed, quoted values and trailing comments) and the older flat + layout. Verified against all 303 schemes in `pkgs.base16-schemes`. + + # Type + + ``` + parseScheme :: String -> { palette :: AttrsOf String, variant :: Null | String, name :: Null | String } + ``` + */ + parseScheme = + text: + let + lines = lib.splitString "\n" text; + + palette = lib.listToAttrs ( + lib.concatMap ( + line: + let + matched = builtins.match colorLine line; + in + if matched == null then + [ ] + else + let + key = lib.toLower (builtins.head matched); + in + lib.optional (bySlug ? ${key}) ( + lib.nameValuePair bySlug.${key} (lib.toLower (lib.elemAt matched 1)) + ) + ) lines + ); + + meta = + key: + lib.foldl' ( + acc: line: + let + matched = builtins.match (metaLine key) line; + in + if matched == null then acc else builtins.head matched + ) null lines; + in + { + inherit palette; + variant = meta "variant"; + name = meta "name"; + }; + + /** + 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 a base16 YAML file + - any other string, treated as a scheme name in `pkgs.base16-schemes`, + e.g. `"gruvbox-dark-hard"` + - an attrset of `base00`..`base0F` colours, with or without `#` prefixes + + Derivations are checked before attrsets because a derivation is also an + attrset, and `pkgs.writeText` is a natural way to pass a scheme inline. + + # Type + + ``` + resolveScheme :: Pkgs -> (AttrsOf String | Path | Derivation | String) -> { palette; variant; name; } + ``` + */ + resolveScheme = + pkgs: scheme: + let + fromFile = source: parseScheme (builtins.readFile source); + + isFile = lib.isPath scheme || lib.isDerivation scheme; + + parsed = + if isFile then + fromFile scheme + else if lib.isString scheme then + if lib.hasInfix "/" scheme then + fromFile scheme + else + fromFile "${pkgs.base16-schemes}/share/themes/${scheme}.yaml" + else if lib.isAttrs scheme then + { + palette = normalizePalette scheme; + variant = scheme.variant or null; + name = scheme.name or null; + } + else + throw "styling: cannot resolve a scheme of type ${builtins.typeOf scheme}"; + + source = + if isFile || lib.isString scheme then "scheme ${toString scheme}" else "the scheme attrset"; + in + parsed // { palette = requireSlots source parsed.palette; }; +in +{ + inherit + slots + parseScheme + resolveScheme + normalizeColor + ; +} diff --git a/lib/style/colors.nix b/lib/style/colors.nix new file mode 100644 index 0000000..21d825c --- /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); +} From c5537f9dbcbdd107cbd8bd0567a83406d3d4492f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Tue, 18 Aug 2026 21:15:18 +0200 Subject: [PATCH 2/3] modules/alacritty: derive settings from styling Add a styling.nix deriving colours, font and opacity from the styling module, as the worked example the README points at, and a check.nix, which alacritty did not have before. The check asserts on the generated config rather than the built wrapper, so it does not build alacritty. Assisted-by: Claude Opus 5 --- CHANGELOG.md | 3 + README.md | 25 +++++++- modules/alacritty/check.nix | 106 ++++++++++++++++++++++++++++++++++ modules/alacritty/module.nix | 1 + modules/alacritty/styling.nix | 65 +++++++++++++++++++++ 5 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 modules/alacritty/check.nix create mode 100644 modules/alacritty/styling.nix diff --git a/CHANGELOG.md b/CHANGELOG.md index f16d2d4..7b5b03e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ 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`, diff --git a/README.md b/README.md index 098145e..4b1a867 100644 --- a/README.md +++ b/README.md @@ -541,10 +541,10 @@ A module opts in by deriving settings from `styling`, gated on 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; } ); - - config.extraPackages = lib.mkIf config.styling.enable [ config.styling.fonts.monospace.package ]; } ``` @@ -559,6 +559,27 @@ Two rules: 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/modules/alacritty/check.nix b/modules/alacritty/check.nix new file mode 100644 index 0000000..76cca3a --- /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 3ff7678..b5a829c 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 0000000..388c3e7 --- /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; + } + ); +} From 220840b20cccc195e02c978b239073bf2dbec2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Wed, 19 Aug 2026 06:35:15 +0200 Subject: [PATCH 3/3] lib/style: trim the scheme parser and colour aliases The parser carried a lowercasing table and an older flat layout branch that nothing in pkgs.base16-schemes uses: all 303 schemes there write the canonical `base0A` under a `palette:` block. Parse that shape only; a file spelling a slot otherwise is reported as missing it. Drop the attrset form of `styling.scheme`. It was redundant with `styling.palette.`, which outranks the scheme anyway, and with it go normalizePalette, normalizeColor and bySlug. Also drop the unread `name` from a parsed scheme; `variant` stays, since polarity reads it. Define `styling.colors` as one read only option computed from the palette rather than 37 individual alias options. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- README.md | 11 +-- checks/styling-colors.nix | 2 +- checks/styling-scheme.nix | 81 ++++++----------- lib/modules/styling.nix | 40 ++++----- lib/style/base16.nix | 179 +++++++++----------------------------- 6 files changed, 93 insertions(+), 222 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b5b03e..db7d5b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ - `lib/modules/styling.nix`: cross-cutting styling module, imported into every wrapper. Provides `styling.scheme` (a base16 scheme by name from - `pkgs.base16-schemes`, by path, or inline), `styling.palette`, + `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 diff --git a/README.md b/README.md index 4b1a867..1b8351a 100644 --- a/README.md +++ b/README.md @@ -429,13 +429,12 @@ passing it to a single `apply` works too: ### Colour scheme -`styling.scheme` accepts the name of any scheme in `pkgs.base16-schemes`, a -path or derivation holding a base16 YAML file, or an inline attrset: +`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; -styling.scheme = { base00 = "1d2021"; base01 = "3c3836"; /* ... */ base0F = "d65d0e"; }; ``` It populates `styling.palette.base00` through `styling.palette.base0F`. Those @@ -473,7 +472,7 @@ the 16 ANSI terminal colours (`black`, `red`, … `white`, `brightBlack`, … | Option | Default | Description | |---|---|---| | `styling.enable` | `styling.scheme != null` | Whether this wrapper styles itself | -| `styling.scheme` | `null` | Scheme name, path, derivation, or attrset | +| `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"` | @@ -519,7 +518,9 @@ behaviour. Enabling it without a scheme is fine too — the palette falls back t 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. +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 diff --git a/checks/styling-colors.nix b/checks/styling-colors.nix index f530c58..636b078 100644 --- a/checks/styling-colors.nix +++ b/checks/styling-colors.nix @@ -25,7 +25,7 @@ let )).apply { inherit pkgs; - styling.scheme = probe; + styling.palette = probe; }; colors = cfg.styling.colors; diff --git a/checks/styling-scheme.nix b/checks/styling-scheme.nix index 00f69e1..8167e7c 100644 --- a/checks/styling-scheme.nix +++ b/checks/styling-scheme.nix @@ -16,63 +16,42 @@ let )).apply { inherit pkgs styling; }; - # A scheme in the older flat layout: no `palette:` block, no `#` prefixes. - legacyFile = pkgs.writeText "legacy-scheme.yaml" '' - scheme: "Legacy" - author: "nobody" - base00: 111111 - 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 + # 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 = legacyFile; }; + byDerivation = styled { scheme = inlineFile; }; byPathString = styled { scheme = "${pkgs.base16-schemes}/share/themes/default-light.yaml"; }; - # `#` prefixes, upper case values and lower case slot names all normalised. - byAttrs = styled { - scheme = { - 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"; - }; - }; - overridden = styled { scheme = "gruvbox-dark-hard"; palette.base0D = "abcdef"; }; incomplete = styled { - scheme = { - base00 = "111111"; - }; + scheme = pkgs.writeText "incomplete-scheme.yaml" '' + palette: + base00: "#111111" + ''; }; missingSlots = builtins.tryEval incomplete.styling.palette.base00; @@ -109,22 +88,18 @@ pkgs.runCommand "styling-scheme-test" { } '' expect "by name polarity" '${byName.styling.polarity}' 'dark' # Scheme metadata is picked up alongside the palette - expect "parsed name" '${parsedGruvbox.name}' 'Gruvbox dark, hard' expect "parsed variant" '${parsedGruvbox.variant}' 'dark' - # A derivation holding a scheme in the older flat layout - expect "by derivation base00" '${byDerivation.styling.palette.base00}' '111111' + # 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' - # An inline attrset: '#' stripped, values lower cased, slot names canonicalised - expect "by attrs base00" '${byAttrs.styling.palette.base00}' 'eeeeee' - expect "by attrs base0A" '${byAttrs.styling.palette.base0A}' 'bbbbbb' # No variant to read, so polarity falls back to the brightness of base00 - expect "by attrs polarity" '${byAttrs.styling.polarity}' 'light' + expect "by derivation polarity" '${byDerivation.styling.polarity}' 'light' # An explicit palette entry outranks the scheme expect "override base0D" '${overridden.styling.palette.base0D}' 'abcdef' diff --git a/lib/modules/styling.nix b/lib/modules/styling.nix index 0e47c81..627949e 100644 --- a/lib/modules/styling.nix +++ b/lib/modules/styling.nix @@ -72,16 +72,6 @@ let brightWhite = "base07"; }; - mkColorAlias = - slot: - lib.mkOption { - type = lib.types.str; - readOnly = true; - default = cfg.palette.${slot}; - defaultText = lib.literalExpression "config.styling.palette.${slot}"; - description = "Alias for `styling.palette.${slot}`."; - }; - mkFont = attr: fontName: { package = lib.mkOption { type = lib.types.package; @@ -141,23 +131,17 @@ in }; scheme = lib.mkOption { - type = lib.types.nullOr ( - lib.types.oneOf [ - lib.types.str - lib.types.path - (lib.types.attrsOf lib.types.str) - ] - ); + 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`, a path to a - base16 YAML file, or an attrset of `base00` to `base0F` colours. + Accepts the name of a scheme in `pkgs.base16-schemes` or a path to a + base16 YAML file. - Individual colours can still be adjusted afterwards by setting - `styling.palette.`, which takes precedence over the scheme. + Individual colours are set through `styling.palette.`, which + takes precedence over the scheme. ''; }; @@ -169,8 +153,18 @@ in } ); - colors = lib.mapAttrs (_: mkColorAlias) aliases // { - ansi = lib.mapAttrs (_: mkColorAlias) ansiAliases; + 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 { diff --git a/lib/style/base16.nix b/lib/style/base16.nix index d4d2b77..bafcfe8 100644 --- a/lib/style/base16.nix +++ b/lib/style/base16.nix @@ -1,84 +1,18 @@ { lib }: let - /** - The canonical base16 slot names. - - The base16 specification spells the hex digit in upper case (`base0A`, not - `base0a`), but scheme files in the wild are inconsistent about it. Parsed - keys are lower-cased and looked up in `bySlug` to normalise them. - */ - slots = [ - "base00" - "base01" - "base02" - "base03" - "base04" - "base05" - "base06" - "base07" - "base08" - "base09" - "base0A" - "base0B" - "base0C" - "base0D" - "base0E" - "base0F" - ]; - - bySlug = lib.listToAttrs (map (slot: lib.nameValuePair (lib.toLower slot) slot) slots); - - # Matches `base0A: "#fabd2f" # yellow` as well as the older flat `base0A: fabd2f`. - colorLine = ''^[[:space:]]*(base[0-9a-fA-F]{2})[[:space:]]*:[[:space:]]*"?#?([0-9a-fA-F]{6})"?.*$''; - - # Matches a top level `variant: "dark"` / `name: "Gruvbox dark, hard"`, - # tolerating both quoting and a trailing comment. - metaLine = key: ''^[[:space:]]*${key}[[:space:]]*:[[:space:]]*"?([^"#]*[^" #])"?.*$''; - - /** - Normalise a single colour to lower case 6 digit hex without a `#` prefix, - which is how palettes are stored throughout the styling module. - */ - normalizeColor = - name: value: - let - matched = builtins.match "#?([0-9a-fA-F]{6})" (toString value); - in - if matched == null then - throw "styling: ${name} is not a 6 digit hex colour, got ${toString value}" - else - lib.toLower (builtins.head matched); + slots = map (char: "base0${char}") (lib.stringToCharacters "0123456789ABCDEF"); - /** - Normalise an attrset palette, dropping keys that are not base16 slots. + # 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})"?.*''; - Unknown keys are ignored rather than rejected so that base24 schemes - (which add `base10`..`base17`) can be passed through unchanged. A typo is - still caught, by `requireSlots` reporting the resulting missing slot. - */ - normalizePalette = - attrs: - lib.listToAttrs ( - lib.concatMap ( - entry: - let - key = lib.toLower entry.name; - in - lib.optional (bySlug ? ${key}) ( - lib.nameValuePair bySlug.${key} (normalizeColor entry.name entry.value) - ) - ) (lib.mapAttrsToList lib.nameValuePair attrs) - ); - - requireSlots = - source: palette: - let - missing = lib.filter (slot: !(palette ? ${slot})) slots; - in - if missing == [ ] then - palette - else - throw "styling: ${source} is missing base16 colours: ${lib.concatStringsSep ", " missing}"; + # 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. @@ -88,109 +22,76 @@ let keeps scheme loading pure. Using a YAML tool would require import from derivation, and CI runs `nix flake check`. - Handles both the current tinted-theming layout (a nested `palette:` block - with `#` prefixed, quoted values and trailing comments) and the older flat - layout. Verified against all 303 schemes in `pkgs.base16-schemes`. - # Type ``` - parseScheme :: String -> { palette :: AttrsOf String, variant :: Null | String, name :: Null | String } + 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 - if matched == null then - [ ] - else - let - key = lib.toLower (builtins.head matched); - in - lib.optional (bySlug ? ${key}) ( - lib.nameValuePair bySlug.${key} (lib.toLower (lib.elemAt matched 1)) - ) + lib.optional (matched != null) ( + lib.nameValuePair (lib.head matched) (lib.toLower (lib.elemAt matched 1)) + ) ) lines ); - meta = - key: - lib.foldl' ( - acc: line: - let - matched = builtins.match (metaLine key) line; - in - if matched == null then acc else builtins.head matched - ) null lines; - in - { - inherit palette; - variant = meta "variant"; - name = meta "name"; + 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 a base16 YAML file - - any other string, treated as a scheme name in `pkgs.base16-schemes`, - e.g. `"gruvbox-dark-hard"` - - an attrset of `base00`..`base0F` colours, with or without `#` prefixes + 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"`. - Derivations are checked before attrsets because a derivation is also an - attrset, and `pkgs.writeText` is a natural way to pass a scheme inline. + Individual colours are set through `styling.palette.`, which + overrides the scheme, so there is no attrset form here. # Type ``` - resolveScheme :: Pkgs -> (AttrsOf String | Path | Derivation | String) -> { palette; variant; name; } + resolveScheme :: Pkgs -> (Path | Derivation | String) -> { palette; variant; } ``` */ resolveScheme = pkgs: scheme: let - fromFile = source: parseScheme (builtins.readFile source); - - isFile = lib.isPath scheme || lib.isDerivation scheme; - - parsed = - if isFile then - fromFile scheme - else if lib.isString scheme then - if lib.hasInfix "/" scheme then - fromFile scheme - else - fromFile "${pkgs.base16-schemes}/share/themes/${scheme}.yaml" - else if lib.isAttrs scheme then - { - palette = normalizePalette scheme; - variant = scheme.variant or null; - name = scheme.name or null; - } + file = + if lib.isPath scheme || lib.isDerivation scheme || lib.hasInfix "/" scheme then + scheme else - throw "styling: cannot resolve a scheme of type ${builtins.typeOf scheme}"; + "${pkgs.base16-schemes}/share/themes/${scheme}.yaml"; - source = - if isFile || lib.isString scheme then "scheme ${toString scheme}" else "the scheme attrset"; + parsed = parseScheme (builtins.readFile file); + missing = lib.filter (slot: !(parsed.palette ? ${slot})) slots; in - parsed // { palette = requireSlots source parsed.palette; }; + if missing == [ ] then + parsed + else + throw "styling: scheme ${toString scheme} is missing base16 colours: ${lib.concatStringsSep ", " missing}"; in { inherit slots parseScheme resolveScheme - normalizeColor ; }