From 7b34be82ff5c3f30a3d5f151f5056aefdbf78df8 Mon Sep 17 00:00:00 2001 From: Daniel Thwaites Date: Sun, 17 Oct 2021 15:04:19 +0100 Subject: [PATCH] Rewrite palette generation in Haskell :sparkles: This does not rely on an external library for colour selection, therefore it can be fine-tuned to create a better theme. Closes #2 because Colorgram is no longer used. --- default.nix | 18 ------- flake.lock | 43 ++++++++++++++++ flake.nix | 56 +++++++++++++++++++- palette-generator/Main.hs | 100 ++++++++++++++++++++++++++++++++++++ palette-generator/RGBHSV.hs | 44 ++++++++++++++++ stylix/colors.nix | 58 --------------------- stylix/colors.py | 100 ------------------------------------ stylix/default.nix | 13 ----- stylix/palette.nix | 55 ++++++++++++++++++++ 9 files changed, 297 insertions(+), 190 deletions(-) delete mode 100644 default.nix create mode 100644 flake.lock create mode 100644 palette-generator/Main.hs create mode 100644 palette-generator/RGBHSV.hs delete mode 100644 stylix/colors.nix delete mode 100644 stylix/colors.py delete mode 100644 stylix/default.nix create mode 100644 stylix/palette.nix diff --git a/default.nix b/default.nix deleted file mode 100644 index 2eb31fa4..00000000 --- a/default.nix +++ /dev/null @@ -1,18 +0,0 @@ -{ - imports = [ - ./stylix/default.nix - - ./modules/console.nix - ./modules/dunst.nix - ./modules/feh.nix - ./modules/fish.nix - ./modules/grub.nix - ./modules/gtk.nix - ./modules/kitty.nix - ./modules/lightdm.nix - ./modules/plymouth - ./modules/qutebrowser.nix - ./modules/sway.nix - ./modules/vim.nix - ]; -} diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..652eda44 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1633422745, + "narHash": "sha256-gA6Ok64nPbkjHk3Oanq4641EeYkjcKhisDF9wBjLxEk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8e1eab9eae4278c9bb1dcae426848a581943db5a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "utils": { + "locked": { + "lastModified": 1631561581, + "narHash": "sha256-3VQMV5zvxaVLvqqUrNz3iJelLw30mIVSfZmAaauM3dA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "7e5bf3925f6fbdfaf50a2a7ca0be2879c4261d19", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index 649b55a3..95702aa4 100644 --- a/flake.nix +++ b/flake.nix @@ -1 +1,55 @@ -{ outputs = inputs: { nixosModules.stylix = import ./default.nix; }; } +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = { nixpkgs, utils, self, ... }: + (utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + ghc = pkgs.haskell.packages.ghc901.ghcWithPackages + (haskellPackages: with haskellPackages; [ json JuicyPixels ]); + + palette-generator = pkgs.stdenvNoCC.mkDerivation { + name = "palette-generator"; + src = ./palette-generator; + buildInputs = [ ghc ]; + buildPhase = "ghc -O -threaded -Wall Main.hs"; + installPhase = "install -D Main $out/bin/palette-generator"; + }; + + palette-generator-app = utils.lib.mkApp { + drv = palette-generator; + name = "palette-generator"; + }; + + in { + packages.palette-generator = palette-generator; + apps.palette-generator = palette-generator-app; + })) // { + nixosModules.stylix = { pkgs, ... }: { + imports = [ + ./modules/console.nix + ./modules/dunst.nix + ./modules/feh.nix + ./modules/fish.nix + ./modules/grub.nix + ./modules/gtk.nix + ./modules/kitty.nix + ./modules/lightdm.nix + ./modules/plymouth + ./modules/qutebrowser.nix + ./modules/sway.nix + ./modules/vim.nix + (import ./stylix/palette.nix + self.packages.${pkgs.system}.palette-generator) + ./stylix/base16.nix + ./stylix/fonts.nix + ./stylix/home-manager.nix + ./stylix/pixel.nix + ]; + }; + }; +} diff --git a/palette-generator/Main.hs b/palette-generator/Main.hs new file mode 100644 index 00000000..53f72d7c --- /dev/null +++ b/palette-generator/Main.hs @@ -0,0 +1,100 @@ +import Codec.Picture ( DynamicImage, Image(imageWidth, imageHeight), PixelRGB8(PixelRGB8), convertRGB8, pixelAt, readImage ) +import Data.Bifunctor ( second ) +import Data.List ( sortOn ) +import Data.Word ( Word8 ) +import RGBHSV ( HSV(HSV), RGB(RGB), hsvToRgb, rgbToHsv ) +import System.Environment ( getArgs ) +import System.Exit ( die ) +import Text.JSON ( JSObject, encode, toJSObject ) +import Text.Printf ( printf ) + +type OutputTable = JSObject String + +makeOutputTable :: [(String, RGB Float)] -> OutputTable +makeOutputTable = toJSObject . concatMap makeOutputs + + where makeOutputs :: (String, RGB Float) -> [(String, String)] + makeOutputs (name, RGB r g b) = + [ (name ++ "-dec-r", show $ r / 255) + , (name ++ "-dec-g", show $ g / 255) + , (name ++ "-dec-b", show $ b / 255) + , (name ++ "-rgb-r", show r') + , (name ++ "-rgb-g", show g') + , (name ++ "-rgb-b", show b') + , (name ++ "-hex-r", printf "%02x" r') + , (name ++ "-hex-g", printf "%02x" g') + , (name ++ "-hex-b", printf "%02x" b') + , (name ++ "-hex", printf "%02x%02x%02x" r' g' b') + , (name ++ "-hash", printf "#%02x%02x%02x" r' g' b') + ] + where r' :: Word8 + r' = round r + g' :: Word8 + g' = round g + b' :: Word8 + b' = round b + +selectColours :: [HSV Float] -> [(String, HSV Float)] +selectColours image = zip names palette + + where names :: [String] + names = map (printf "base%02X") ([0..15] :: [Int]) + + hueInRange :: (Ord a) => a -> a -> HSV a -> Bool + hueInRange low high (HSV hue _ _) = hue >= low && hue < high + + binThresholds :: [Float] + binThresholds = [i * (6/9) | i <- [1..9]] + + average :: (Fractional a) => [a] -> a + average xs = sum xs / fromIntegral (length xs) + + averageColour :: (Fractional a) => [HSV a] -> HSV a + averageColour colours = HSV (average $ map (\(HSV h _ _) -> h) colours) + (average $ map (\(HSV _ s _) -> s) colours) + (average $ map (\(HSV _ _ v) -> v) colours) + + bins :: [HSV Float] + bins = map averageColour + $ sortOn length + $ map (\bin -> filter (hueInRange (bin - (6/9)) bin) image) + binThresholds + + primaryScale :: [HSV Float] + primaryScale = [HSV h s (v / 8) | v <- [1..8]] + where (HSV h s _) = head bins + + palette :: [HSV Float] + palette = primaryScale ++ tail bins + +unpackImage :: DynamicImage -> [RGB Float] +unpackImage image = do + let image' = convertRGB8 image + x <- [0 .. imageWidth image' - 1] + y <- [0 .. imageHeight image' - 1] + let (PixelRGB8 r g b) = pixelAt image' x y + return (RGB (fromIntegral r) (fromIntegral g) (fromIntegral b)) + +loadImage :: String -> IO DynamicImage +loadImage input = either error id <$> readImage input + +main :: IO () +main = either die mainProcess . parseArguments =<< getArgs + + where parseArguments :: [String] -> Either String (String, String) + parseArguments [input, output] = Right (input, output) + parseArguments [_] = Left "Please specify an output file" + parseArguments [] = Left "Please specify an image" + parseArguments _ = Left "Too many arguments" + + mainProcess :: (String, String) -> IO () + mainProcess (input, output) = do + putStrLn $ "Processing " ++ input + image <- loadImage input + let outputTable = makeOutputTable + $ map (second hsvToRgb) + $ selectColours + $ map rgbToHsv + $ unpackImage image + writeFile output $ encode outputTable + putStrLn $ "Saved to " ++ output diff --git a/palette-generator/RGBHSV.hs b/palette-generator/RGBHSV.hs new file mode 100644 index 00000000..d495de70 --- /dev/null +++ b/palette-generator/RGBHSV.hs @@ -0,0 +1,44 @@ +module RGBHSV ( RGB(..), HSV(..), rgbToHsv, hsvToRgb ) where + +import Data.Fixed ( mod' ) + +-- http://mattlockyer.github.io/iat455/documents/rgb-hsv.pdf + +data RGB a = RGB a a a deriving (Eq, Show) -- 0 to 255 +data HSV a = HSV a a a deriving (Eq, Show) -- 0 to 1 + +normaliseHue :: (Real a) => a -> a +normaliseHue h = h `mod'` 6 + +rgbToHsv :: (Eq a, Fractional a, Num a, Real a) => RGB a -> HSV a +rgbToHsv (RGB r' g' b') = HSV h' s v + where r = r' / 255 + g = g' / 255 + b = b' / 255 + maximal = maximum [r, g, b] + minimal = minimum [r, g, b] + delta = maximal - minimal + h | delta == 0 = 0 + | maximal == r = (g - b) / delta + | maximal == g = ((b - r) / delta) + 2 + | otherwise = ((r - g) / delta) + 4 + h' = normaliseHue h + s | v == 0 = 0 + | otherwise = delta / v + v = maximal + +hsvToRgb :: (Num a, Ord a, Real a) => HSV a -> RGB a +hsvToRgb (HSV h' s v) = RGB r' g' b' + where h = normaliseHue h' + alpha = v * (1 - s) + beta = v * (1 - (h - abs h) * s) + gamma = v * (1 - (1 - (h - abs h)) * s) + (r, g, b) | h < 1 = (v, gamma, alpha) + | h < 2 = (beta, v, alpha) + | h < 3 = (alpha, v, gamma) + | h < 4 = (alpha, beta, v) + | h < 5 = (gamma, alpha, v) + | otherwise = (v, alpha, beta) + r' = r * 255 + g' = g * 255 + b' = b * 255 diff --git a/stylix/colors.nix b/stylix/colors.nix deleted file mode 100644 index 40c4a907..00000000 --- a/stylix/colors.nix +++ /dev/null @@ -1,58 +0,0 @@ -{ pkgs, lib, config, ... }: - -with lib; - -let - cfg = config.stylix; - - # TODO: This Python library should be included in Nixpkgs - colorgram = with pkgs.python3Packages; - buildPythonPackage rec { - pname = "colorgram.py"; - version = "1.2.0"; - src = fetchPypi { - inherit pname version; - sha256 = "1gzxgcmg3ndra2j4dg73x8q9dw6b0akj474gxyyhfwnyz6jncxz7"; - }; - propagatedBuildInputs = [ pillow ]; - }; - colorgramPython = pkgs.python3.withPackages (ps: [ colorgram ]); - - # Pass the wallpaper and any manually selected colors to ./colors.py and - # return a JSON file containing the generated colorscheme - colorsJSON = pkgs.runCommand "stylix-colors" { - colors = builtins.toJSON config.stylix.colors; - passAsFile = [ "colors" ]; - } '' - ${colorgramPython}/bin/python ${./colors.py} \ - ${cfg.image} < $colorsPath > $out - ''; - -in { - options.stylix.colors = genAttrs [ - "base00" - "base01" - "base02" - "base03" - "base04" - "base05" - "base06" - "base07" - "base08" - "base09" - "base0A" - "base0B" - "base0C" - "base0D" - "base0E" - "base0F" - ] (name: - mkOption { - description = "Hexadecimal color value for ${name}."; - default = null; - defaultText = "Automatically selected from the background image."; - type = types.nullOr (types.strMatching "[0-9a-fA-F]{6}"); - }); - - config.lib.stylix.colors = importJSON colorsJSON; -} diff --git a/stylix/colors.py b/stylix/colors.py deleted file mode 100644 index c8d7d760..00000000 --- a/stylix/colors.py +++ /dev/null @@ -1,100 +0,0 @@ -import json -import sys -import colorgram -import colorsys - - -# Select 9 colors from the image passed on the command line -colors = colorgram.extract(sys.argv[1], 9) - - -# Extract the most dominant color to use as the background -colors.sort(key=lambda c: c.proportion) -dominant_color = colors.pop(0) - -# Decide whether to generate a light or dark scheme based -# on the lightness of the dominant color -if dominant_color.hsl.l >= 128: - def scale(i): - scale = 0.7 - (0.1 * i) - return scale * 255 - - def clamp(l): - return min(l, 100) -else: - def scale(i): - scale = 0.1 + (0.1 * i) - return scale * 255 - - def clamp(l): - return max(l, 155) - - -def int_to_base(i): - return "base{0:02X}".format(i) - -scheme = {} - -# base00 to base07 use the dominant color's hue and saturation, -# lightness is a linear scale -for i in range(8): - scheme[int_to_base(i)] = ( - dominant_color.hsl.h, - scale(i), - dominant_color.hsl.s, - ) - -# base08 to base0A use the remaining 8 colors from the image, -# with their lightness clamped to enforce adequate contrast -colors.sort(key=lambda c: c.hsl.h) # sort by hue -for i in range(8, 16): - color = colors[i-8] - scheme[int_to_base(i)] = ( - color.hsl.h, - clamp(color.hsl.l), - color.hsl.s, - ) - -# Override with any manually selected colors -manual_colors = json.load(sys.stdin) -for k, v in manual_colors.items(): - if v is not None: - scheme[k] = colorsys.rgb_to_hls( - int(v[0:2], 16) / 255, - int(v[2:4], 16) / 255, - int(v[4:6], 16) / 255, - ) - scheme[k] = ( - scheme[k][0] * 255, - scheme[k][1] * 255, - scheme[k][2] * 255, - ) - - -data = {} - -for key, color in scheme.items(): - r, g, b = colorsys.hls_to_rgb( - color[0] / 255, - color[1] / 255, - color[2] / 255, - ) - data[key + "-dec-r"] = r - data[key + "-dec-g"] = g - data[key + "-dec-b"] = b - data[key + "-rgb-r"] = r * 255 - data[key + "-rgb-g"] = g * 255 - data[key + "-rgb-b"] = b * 255 - - hex_color = "{0:02x}{1:02x}{2:02x}".format( - round(r * 255), - round(g * 255), - round(b * 255), - ) - data[key + "-hex"] = hex_color - data[key + "-hash"] = "#" + hex_color - data[key + "-hex-r"] = hex_color[0:2] - data[key + "-hex-g"] = hex_color[2:4] - data[key + "-hex-b"] = hex_color[4:6] - -json.dump(data, sys.stdout) diff --git a/stylix/default.nix b/stylix/default.nix deleted file mode 100644 index 6f646b74..00000000 --- a/stylix/default.nix +++ /dev/null @@ -1,13 +0,0 @@ -{ lib, ... }: - -with lib; - -{ - imports = - [ ./base16.nix ./colors.nix ./fonts.nix ./home-manager.nix ./pixel.nix ]; - - options.stylix.image = mkOption { - type = types.coercedTo types.package toString types.path; - description = "Wallpaper image."; - }; -} diff --git a/stylix/palette.nix b/stylix/palette.nix new file mode 100644 index 00000000..6423023f --- /dev/null +++ b/stylix/palette.nix @@ -0,0 +1,55 @@ +palette-generator: +{ pkgs, lib, config, ... }: + +with lib; + +let + cfg = config.stylix; + + palette = pkgs.runCommand "palette.json" { } '' + ${palette-generator}/bin/palette-generator ${cfg.image} $out + ''; + +in { + options.stylix = { + image = mkOption { + type = types.coercedTo types.package toString types.path; + description = '' + Wallpaper image. This is set as the background of your desktop + environment, if possible, and additionally used as the Plymouth splash + screen if that is enabled. Colours are automatically selected from the + picture to generate the system colour scheme. + ''; + }; + + /* + TODO: Implement manual palette + palette = genAttrs [ + "base00" + "base01" + "base02" + "base03" + "base04" + "base05" + "base06" + "base07" + "base08" + "base09" + "base0A" + "base0B" + "base0C" + "base0D" + "base0E" + "base0F" + ] (name: + mkOption { + description = "Hexadecimal color value for ${name}."; + default = null; + defaultText = "Automatically selected from the background image."; + type = types.nullOr (types.strMatching "[0-9a-fA-F]{6}"); + }); + */ + }; + + config.lib.stylix.colors = importJSON palette; +}