Skip to content
termworksPublic

About

ANSI color generator for your terminal

Resources

Stars

74 stars

Watchers

1 watching

Forks

Repository files navigation

lule

A command line tool to generate 8bit ANSI colors from wallpaper (an enhanced version of pywal but in V) There is the old bash version in: https://github.com/warpwm/lule_bash

lule create -- set


Features

In order for lule to work properly, you need to set the following environment variables:

  • LULE_W : The path to the wallpaper (one random image will be selected from this directory)
export LULE_W="~/.wallpaper"

lule create -- set

Building

nix develop --command oslo make build

One statically linked binary at target/lule, including the SVG renderer and PNG encoder. There are no runtime renderer commands, shared libraries, or launcher scripts. The Nix build supplies Lua and the resvg static archive. Outside Nix, install a V/C toolchain, static Lua 5.4, and resvg's C static library, with lua5.4.pc and resvg-static.pc on PKG_CONFIG_PATH:

v -prod -cflags -static src/ -o target/lule

oslo make lists the rest — dev, test, verify, install, docs, release.

Generate a wallpaper

lule wallpaper

Enter a logo path (SVG or PNG) and its size percentage. Each run creates one PNG with a randomly chosen, code-generated pattern, a centered logo, and a background that fades darker toward the bottom. It does not set the desktop wallpaper or modify your originals. The default canvas is 3456×2160, matching the reference wallpapers.

Omit --color to automatically choose a bright, colorful background without a prompt. Random colors use 65–85% HSL saturation and 62–70% lightness, avoiding white, black, gray, and very dark shades. --color still accepts any explicit hex color. --seed reproduces both the random color and pattern.

Size is the logo's bounding square as a percentage of the shorter canvas side (default 35%). Aspect ratio, transparency, and logo colors are preserved; padding inside the logo counts toward its size. Use a self-contained SVG or transparent PNG without unwanted margins.

lule wallpaper --logo=~/.dot/.bresilla/logo.svg --size=40
lule wallpaper --logo=~/.dot/.bresilla/logo.svg --size=35 --color='#ff9eae'
lule wallpaper --logo=logo.png --size=40 --color='#80b8cd' \
  --style=honey --seed=42 --output=wallpaper.png
lule wallpaper --logo=logo.svg --size=35 --color='#80b8cd' \
  --style=contours --format=svg --output=wallpaper.svg

There are 43 patterns, all included in the random selection. List them without generating:

lule wallpaper --list-patterns
lule wallpaper --logo=logo.svg --size=40 --style=flowfield
Family Pattern names
Original collection cross, digi, dome, dots, grid, hexag, honey, nato, stars, triang, contours
Geometric and woven diagonals, chevrons, herringbone, basketweave, brick, diamonds, octagons, scales, seigaiha, circles, quatrefoil, greekkey, pinwheel, asanoha, cubes, crosshatch
Routed tiles truchet, maze, circuit
Flowing lines waves, ribbons, flowfield
Cellular and ornamental voronoi, pebbles, constellation, phyllotaxis, spirals, rosettes
Recursive geometry sierpinski, koch, hilbert, branches

All geometry is generated in V, not copied from a texture or downloaded at runtime. Random tile orientations, noise fields, jittered sites, and branching are seeded. A fixed --style and --seed reproduce the same pattern; automatic style selection can change when new patterns are added to the list. Recursion depth and geometry density are bounded.

Algorithm references: Truchet tiling and patterns, flow fields, Voronoi cells, recursive botanical models, and fractal noise.

The default output is <pattern>-<seed>.png in the current directory. --output can point into an existing directory, including ~/.wallpaper/. Existing files, including symlinks, are never overwritten. --width and --height change the resolution (64–8192 per side, at most 40 million pixels). Flags accept either --name=value or --name value.

PNG export uses the statically linked resvg C API and V's built-in stb PNG encoder. These are build dependencies only; copying the executable is enough to use it. SVG logos should have text converted to paths; system fonts are not loaded. SVG output embeds the logo, so moving the wallpaper does not break the logo link. Run lule wallpaper --help for all options.

oslo make verify includes PNG export with an empty environment and no commands on PATH. oslo make wallpaper-isolation additionally tests both logo formats in an empty filesystem and network namespace (requires bubblewrap as a test tool, not a runtime dependency). oslo make wallpaper-gallery --logo=/path/to/logo.svg creates an HTML preview of every pattern under target/pattern-gallery/; use --output=NEW_DIRECTORY for a fresh gallery.

For non-Nix builds, build the resvg C library and install its header and archive. The resvg-static.pc file must point to the archive explicitly rather than a shared library (replace /opt/resvg with your installation):

Name: resvg-static
Description: Statically linked SVG renderer
Version: 0.48.1
Libs: -L/opt/resvg/lib -l:libresvg.a -ldl -lpthread -lm
Cflags: -I/opt/resvg/include

Nix binary cache

Each package and architecture uses one stable pin, such as lule-x86_64-linux, with --keep-revisions 5. The five newest pin revisions are protected from cache cleanup; each binary retains its actual package version. Older revisions become eligible for garbage collection and may need rebuilding.

Tagged releases are cached for x86_64-linux and aarch64-linux:

cachix use termworks
nix build --accept-flake-config github:termworks/lule/v0.5.5
nix run --accept-flake-config github:termworks/lule/v0.5.5 -- --version

The cache is https://termworks.cachix.org, with public signing key termworks.cachix.org-1:Ty7sSVALfD5ajbcWBIdaNHcaEx3fEmVrOo+rSzy0mvE=. Only pushed v* tags publish to the cache; branch revisions may need compilation. Use oslo make nix-build and oslo make nix-check for local package checks.

From another flake, set inputs.lule.url = "github:termworks/lule/v0.5.5" and use lule.packages.${system}.default. Enable the cache on the consuming machine with cachix use termworks; input flakes do not apply their nixConfig automatically.

Templates that ship

templates/ holds one per format, not per application - a css file themes anything that reads css, an ini file anything that reads ini:

colors.sh  colors.css  colors.scss  colors.ini  colors.toml  colors.json  colors.Xresources

Point a template at wherever the program expects it:

lule.template("gtk", {
  input  = "~/.config/lule/templates/colors.css",
  output = "~/.config/gtk-4.0/colors.css",
})

They use ansi, which is the sixteen terminal colours - colors is all 256:

<* for c in ansi *>--color{{ loop_index }}: {{ c.hex }};
<* endfor *>

Configuration

Put init.lua in ~/.config/lule/ (or $LULE_C/). Settings are assigned, everything else is registered, and nothing is returned — the same shape oslo is configured in. resources/init.example.lua is a commented starting point.

local lule = require("lule")

lule.theme    = "dark"
lule.palette  = "pigment"
lule.contrast = "aa"

-- One list drives every path, which is what a config written in Lua buys you:
-- adding an application is a word, not six lines.
for _, app in ipairs({ "kitty", "waybar", "rofi" }) do
  lule.template(app, {
    input  = "~/.config/lule/templates/colors.ini",
    output = "~/.config/" .. app .. "/colors.ini",
  })
end

Settings are wallpaper, theme, palette, contrast, scheme, sort, saturation, illumination, hue, blend, seed, loop, norandom and cache — the flags, by the same names. One the config never mentions is left alone.

lule.template(name, spec) registers a template, keyed on its name: registering the same name again replaces it rather than adding a second. The order they are called in is the order they render in, and lule.templates is the list being registered into, so it can be assigned outright.

Precedence runs file, then environment, then flags - a flag always wins. ~ is expanded by lule, since nothing in a config file passes through a shell. --pattern adds to what the file lists rather than replacing it.

A broken config stops the run rather than falling back to defaults, and Lua names the file and line: init.lua:12: syntax error near '='. Carrying on would quietly apply a scheme you did not ask for, over the top of the one you had.

Doing things once the colours exist

lule.on.colors(fn) registers a handler, called once the colours, the cache and the templates are all done. It is where a post-generation shell script would otherwise go — and it can be called as often as you like, so that script becomes several small functions rather than one big one.

local function write_cache(c)
  lule.mkdir("~/.cache/wal")
  lule.write("~/.cache/wal/colors", table.concat(c.colors, "\n"))
end

-- escape sequences down every open terminal: recolours a running shell in place
local function recolour_terminals(c)
  local esc = string.char(27)
  local seq = esc .. "]11;" .. c.background .. esc .. "\\"
  for i, hex in ipairs(c.ansi) do
    seq = seq .. esc .. "]4;" .. (i - 1) .. ";" .. hex .. esc .. "\\"
  end
  lule.ttys(seq)
end

local function reload_desktop(c)
  lule.run('hyprctl hyprpaper wallpaper ",' .. c.wallpaper .. ',"')
  lule.spawn("zedtheme")
end

lule.on.colors(write_cache)
lule.on.colors(recolour_terminals)
lule.on.colors(reload_desktop)

Handlers run in the order they were registered, and one that raises is reported without stopping the ones after it. A handler is pure side effect — whatever it returns is ignored.

c is the finished scheme: c.colors (all 256), c.ansi (the sixteen), c.background, c.foreground, c.cursor, c.accent, c.wallpaper, c.theme, c.cache. Lists count from one, so c.colors[1] is colour 0.

files lule.write(path, text) lule.append(path, text) lule.read(path) lule.copy(from, to) lule.mkdir(path)
commands lule.run(cmd) returns its exit status; lule.spawn(cmd) does not wait
terminals lule.ttys(text) writes to every open pty, and answers how many
environment lule.env(name)

Paths take ~. lule.read and lule.env answer nil when there is nothing there, so lule.read(p) or "default" reads the way it looks. A failing hook is reported and the run stands: the colours are already written by then, and throwing them away would be worse.

Splitting the config up

A config can require the files beside it, so it does not have to be one file. The second file requires the module and registers more; it returns nothing, and nothing has to merge it.

-- ~/.config/lule/init.lua
local lule = require("lule")
lule.theme = "dark"
require("terminals")

-- ~/.config/lule/terminals.lua
local lule = require("lule")
lule.template("kitty", { input = "…", output = "…" })
lule.on.colors(function(c) ... end)

lule.template is keyed on its name, so registering kitty again later replaces it rather than rendering twice — that is how a required file's template gets overridden. The replacement keeps the position the original had, so overriding one does not reshuffle the others. lule.on.colors accumulates instead: handlers for one event are meant to add up.

Environment

variable what
LULE_W directory to pick a wallpaper from at random
LULE_C config directory: init.lua and the named color schemes
LULE_A directory to write the color cache into
LULE_STDIN_MS how long to wait for a piped scheme (default 250ms)

Tuning the palette

Every knob applies to the six colors chosen from the wallpaper, and the rest of the scheme is derived from those — so the background stays dark on a dark theme however hard you push.

lule create --saturation=-1.0 -- set     # greyscale
lule create --saturation=0.5 -- set      # half again as vivid
lule create --illumination=0.2 -- set    # lighter
lule create --hue=180 -- set             # rotate round the wheel
lule create --blend=0.6 -- set           # pull everything toward the accent
lule create --sort=hue -- set            # choose the accent by hue rather than dominance

--sort takes dominance (default), hue, light, dark or chroma.

Readability

The sixteen ANSI colours are held to a minimum WCAG contrast against the background, so no slot comes out too dim to read. Measured before this existed, 3 to 5 of the 15 fell below AA on real wallpapers.

lule create -- set                       # AA (4.5:1), the default
lule create --contrast=aaa -- set        # 7:1
lule create --contrast=3.0 -- set        # a ratio of your own
lule create --contrast=none -- set       # off, colours exactly as extracted

Only lightness moves, and only as far as it has to, so hues survive. Colour 0 is the background everything is measured against and the ramps past 15 are gradients rather than text, so both are left alone.

Reproducible schemes

The gradients are randomised, so the same wallpaper gives a different scheme each time. --seed fixes both the palette extraction and the ramps, which is what lets two machines agree:

lule create --image=~/wall.png --seed=42 -- set

Named schemes

A file of hex colors in $LULE_C, one per line, #-comments ignored — used instead of extracting from the wallpaper:

$ cat ~/.config/lule/gruvbox
# gruvbox dark
#cc241d
#98971a
#d79921

$ lule create --scheme=gruvbox -- set

Output

lule colors -- ansii     # the 256 swatches
lule colors -- list      # each color with its rgb, hsl, lab and lch values
lule colors -- image     # the wallpaper, in the terminal
lule colors -- mix       # both
lule colors -- json      # the scheme as json, for other tools
lule colors | ...        # piped: one hex color per line

Daemon

lule daemon -- detach          # background it, cycling every 300s
lule daemon --loop=600 -- start # foreground
lule daemon -- next            # skip to another wallpaper
lule daemon -- status          # is one running?
lule daemon -- stop

Only one daemon runs at a time; a second refuses to start rather than fighting the first over the control pipe.

Templates

A template is any file with placeholders; --pattern=IN:OUT renders IN over OUT. The syntax follows matugen's, so templates read much the same either way.

Values

color0 … color255, plus background, foreground, cursor, accent, and the string values wallpaper and theme. dark and light are booleans. colors and pigments are lists.

A bare {{ color1 }} prints hex without the leading #, which is what it has always done — existing templates write '#{{ color1 }}' and supply their own.

Colour formats

{{ accent.hex }}            #3f51b5
{{ accent.hex_stripped }}   3f51b5
{{ accent.hex_alpha }}      #3f51b5ff
{{ accent.rgb }}            rgb(63, 81, 181)
{{ accent.rgba }}           rgba(63, 81, 181, 1.00)
{{ accent.hsl }}            hsl(230, 48%, 48%)
{{ accent.red }} {{ accent.green }} {{ accent.blue }} {{ accent.alpha }}
{{ accent.hue }} {{ accent.saturation }} {{ accent.lightness }} {{ accent.luminance }}

Filters

Values stay colours between stages, so filters chain:

{{ accent | lighten: 0.2 | grayscale }}
colour lighten darken saturate desaturate rotate grayscale invert complement
set set_hue set_saturation set_lightness set_alpha
combine mix: "#ff0000", 0.5
readable contrast — black or white, whichever reads against the input
text upper lower trim capitalize replace: "a", "b" default: "fallback"
case snake_case kebab_case camel_case pascal_case

A literal works as the input too: {{ "#3f51b5" | lighten: 0.1 }}.

The case filters take the input apart whatever convention it arrived in, so helloWorld, hello-world and HELLO_WORLD all snake_case to hello_world. A run of capitals stays one word until the last of them (XMLHttpRequest becomes xml_http_request), and digits stay attached to the word before them, so color0 does not become color_0.

upper_case, lower_case and the _case names are matugen's spellings and work here too.

Conditionals and loops

<* if dark *>set background dark<* else *>set background light<* endif *>
<* if theme == "dark" *>…<* endif *>
<* if not light *>…<* endif *>

<* for c in colors *>{{ c.hex }}<* if not loop_last *>, <* endif *><* endfor *>

Inside a loop, loop_index, loop_first and loop_last are available.

Arithmetic

{{ 2 + 3 * 4 }}        14 - precedence, not left to right
{{ (2 + 3) * 4 }}      20
{{ 5 / 2 }}            2.5 - whole results print as integers
{{ count * 2 + 1 }}    names holding numbers work as operands

+ - * / % with parentheses and unary minus. Division or modulo by zero is reported rather than producing an infinity.

Ranges

<* for i in 0..5 *>{{ i }}<* endfor *>      01234   - stops before 5, as in Rust
<* for i in 0..=5 *>{{ i }}<* endfor *>     012345  - inclusive
<* for i in -2..2 *>{{ i }}<* endfor *>     -2 -1 0 1
<* for i in 0..count * 2 *>...<* endfor *>  both ends may be expressions

A backwards range such as 10..0 is empty rather than counting down - counting down would be a silent guess about what was meant. A range longer than 100000 steps is refused and reported, so a mistyped bound says so instead of appearing to hang.

Includes

<* include "partial.conf" *>

The path is resolved beside the file the include appears in, not beside wherever lule was run from, so a partial next to its template is just its name. The content is spliced into the tree rather than rendered separately, which means an include inside a loop can use the loop variable:

<* for c in colors *><* include "row.conf" *><* endfor *>

A file may be included any number of times, but a file that ends up including itself is refused and reported - including when the cycle only shows up after the paths are resolved, so sub/../sub/x.conf and sub/x.conf are recognised as the same file. Nesting stops at 16 deep.

When something is wrong

An unknown name, field or filter is reported on stderr and the placeholder is left in the output rather than replaced with nothing — a config full of blanks is harder to diagnose than one that still shows what failed.

About

ANSI color generator for your terminal

Resources

Stars

74 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages