Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Colour your apps

Turn the wallpaper’s colours into config files other programs read: a kitty theme, GTK and Qt colour definitions. The pipeline is palette.quantize, then palette.score, then palette.scheme once for dark and once for light, then a template renderer, then file.write. A reload hook runs only when a file’s bytes changed.

Render matugen templates

This renderer fills the placeholder subset {{colors.<role>.<default|light|dark>.<format>}}. Roles are palette.scheme’s snake_case names, the ones matugen uses. Formats are hex (#RRGGBB, uppercase like palette.scheme), hex_stripped, rgb, rgba (alpha 1.0) and hsl. default is the mode you pass. A placeholder it cannot resolve stays as written. Not supported: filters ({{ ... | lighten: 10 }}) and <* ... *> blocks, so templates using them need those lines edited.

local function hsl(r, g, b)
    r, g, b = r / 255, g / 255, b / 255
    local hi, lo = math.max(r, g, b), math.min(r, g, b)
    local l, h, s = (hi + lo) / 2, 0, 0
    if hi ~= lo then
        local d = hi - lo
        s = l > 0.5 and d / (2 - hi - lo) or d / (hi + lo)
        if hi == r then h = (g - b) / d + (g < b and 6 or 0)
        elseif hi == g then h = (b - r) / d + 2
        else h = (r - g) / d + 4 end
    end
    local function round(x) return math.floor(x + 0.5) end -- `%d` raises on a float
    return string.format("hsl(%d, %d%%, %d%%)", round(h * 60), round(s * 100), round(l * 100))
end

local function format(hex, how)
    local r, g, b = tonumber(hex:sub(2, 3), 16), tonumber(hex:sub(4, 5), 16), tonumber(hex:sub(6, 7), 16)
    if how == "hex" then return hex end
    if how == "hex_stripped" then return hex:sub(2) end
    if how == "rgb" then return string.format("rgb(%d, %d, %d)", r, g, b) end
    if how == "rgba" then return string.format("rgba(%d, %d, %d, 1.0)", r, g, b) end
    if how == "hsl" then return hsl(r, g, b) end
end

--- `schemes` is { dark = palette.scheme(...), light = palette.scheme(...) }; `mode` is "dark" or "light".
local function render(template, schemes, mode)
    return (template:gsub("{{%s*colors%.([%w_]+)%.(%w+)%.([%w_]+)%s*}}", function(role, which, how)
        local scheme = schemes[which == "default" and mode or which]
        local hex = scheme and scheme[role]
        return hex and format(hex, how) or nil
    end))
end

local seed = "#6750A4"
local schemes = {
    dark = palette.scheme(seed, { dark = true }),
    light = palette.scheme(seed, { dark = false }),
}
return text {
    content = render("background {{colors.surface.default.hex}} / {{colors.primary.light.rgb}} / {{colors.primary.dark.hsl}}", schemes, "dark"),
}

Save the format and render functions (with hsl) as app_colours.lua in your config directory, ending in return render.

Reference templates

kitty, kitty.conf.tpl, written to ~/.config/kitty/mantle.conf. Add include mantle.conf to kitty.conf:

foreground {{colors.on_surface.default.hex}}
background {{colors.surface.default.hex}}
selection_foreground {{colors.on_primary.default.hex}}
selection_background {{colors.primary.default.hex}}
cursor {{colors.primary.default.hex}}
url_color {{colors.tertiary.default.hex}}
active_border_color {{colors.primary.default.hex}}
inactive_border_color {{colors.outline_variant.default.hex}}

GTK, gtk.css.tpl, for ~/.config/gtk-3.0/colors.css and gtk-4.0/colors.css. Add @import 'colors.css'; at the top of the gtk.css beside it:

@define-color accent_color {{colors.primary.default.hex}};
@define-color accent_bg_color {{colors.primary_container.default.hex}};
@define-color accent_fg_color {{colors.on_primary_container.default.hex}};
@define-color window_bg_color {{colors.surface.default.hex}};
@define-color window_fg_color {{colors.on_surface.default.hex}};
@define-color headerbar_bg_color {{colors.surface_container.default.hex}};
@define-color view_bg_color {{colors.surface_container_low.default.hex}};

qt6ct, mantle.conf.tpl, for ~/.config/qt6ct/colors/mantle.conf. The 22 entries per palette are Qt’s QPalette::ColorRole indices in order, which qt6ct reads by position: WindowText, Button, Light, Midlight, Dark, Mid, Text, BrightText, ButtonText, Base, Window, Shadow, Highlight, HighlightedText, Link, LinkVisited, AlternateBase, NoRole, ToolTipBase, ToolTipText, PlaceholderText, Accent:

[ColorScheme]
active_colors={{colors.on_surface.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface_container_high.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface_dim.default.hex}}, {{colors.outline_variant.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface.default.hex}}, {{colors.shadow.default.hex}}, {{colors.primary.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.tertiary.default.hex}}, {{colors.secondary.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface.default.hex}}, {{colors.surface_container_highest.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.primary.default.hex}}
disabled_colors={{colors.on_surface_variant.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface_container_high.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface_dim.default.hex}}, {{colors.outline_variant.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface.default.hex}}, {{colors.shadow.default.hex}}, {{colors.outline.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.tertiary.default.hex}}, {{colors.secondary.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface.default.hex}}, {{colors.surface_container_highest.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.outline.default.hex}}
inactive_colors={{colors.on_surface.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface_container_high.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface_dim.default.hex}}, {{colors.outline_variant.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface.default.hex}}, {{colors.shadow.default.hex}}, {{colors.primary.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.tertiary.default.hex}}, {{colors.secondary.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface.default.hex}}, {{colors.surface_container_highest.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.primary.default.hex}}

Qt reads #RRGGBB or #AARRGGBB; select the file in qt6ct’s Appearance tab once.

The pipeline

The wallpaper path is yours. file.read fetches each template, file.write writes the output, and process.run signals the app only when changed is true, so an unchanged wallpaper restarts nothing.

local render = require("app_colours")

local targets = {
    { template = "kitty.conf.tpl", out = "~/.config/kitty/mantle.conf", reload = { "pkill", { "-USR1", "kitty" } } },
    { template = "gtk.css.tpl", out = "~/.config/gtk-4.0/colors.css" },
    { template = "mantle.conf.tpl", out = "~/.config/qt6ct/colors/mantle.conf" },
}

local function apply(seed, mode)
    local schemes = {
        dark = palette.scheme(seed, { dark = true }),
        light = palette.scheme(seed, { dark = false }),
    }
    for _, target in ipairs(targets) do
        file.read(mantle.config_dir .. "/templates/" .. target.template, function(template, err)
            if not template then return log.warn("app colours:", err) end
            file.write(target.out, render(template, schemes, mode), nil, function(changed, werr)
                if werr then return log.warn("app colours:", werr) end
                if changed and target.reload then
                    process.run(target.reload[1], target.reload[2], function() end, function() end)
                end
            end)
        end)
    end
end

palette.quantize(mantle.config_dir .. "/wallpaper.png", { depth = 7 }, function(found)
    if found then apply(palette.score(found)[1], "dark") end
end)

A reload drops callbacks still pending. If one lands first, the file is written but its hook skipped, and the next run reports changed = false. Touch the output by hand to force the hook.

file.write replaces a file by renaming a temporary over it, which ends an inotify watch an app holds on that one path. For an app that live-reloads a single file, pass { in_place = true } in the opts slot: it rewrites the same inode and keeps the watch. A crash mid-write leaves a truncated file, so use it only where the app needs it.

file.write(target.out, text, { in_place = true }, on_done)

Recolour the shell itself

Keep the theme in one state, or a persistent_table key to survive restarts, and derive every colour token as a :map over it. Each node reading a token repaints when the theme changes; nothing else needs rebuilding.

local seed = "#6750A4"
local schemes = {
    dark = palette.scheme(seed, { dark = true }),
    light = palette.scheme(seed, { dark = false }),
}
local theme = state("theme", "dark")

local function token(role)
    return theme:map(function(name) return schemes[name][role] end)
end
local surface, primary = token("surface"), token("primary")

return rect {
    padding = 12, radius = 8,
    background = surface,
    -- an alpha suffix goes inside the map: a signal does not concatenate
    border_color = primary:map(function(hex) return hex .. "80" end),
    border_width = 2,
    on_click = function() theme:set(theme:get() == "dark" and "light" or "dark") end,
    children = {
        rect { width = 80, height = 6, background = {
            gradient = "linear", angle = 90, stops = { { 0, primary }, { 1, surface } },
        } },
    },
}

What takes a signal:

Where the colour goesPass
A property, such as backgroundThe token
A gradient stop’s colour, a shader params entryThe token, or a :map of it, in the table. The walk reaches 8 tables deep and skips a table with a metatable
A string built from a token, such as token .. "80"The whole string as one :map. .. on a signal raises
A whole gradient or params tableOne :map that returns the full table

A shader’s params hold numbers, never hex: map the token to { r, g, b } in 0..1.

React to a theme change

on_change runs fn(current, previous) after any write: :set or mantle set on a state; store:set or another process editing the file for a key. Rewrite the templates and relaunch from there instead of watching mantle.storage and diffing keys by hand.

local settings = persistent_table { path = "~/.local/state/myshell/theme.json", defaults = { mode = "dark" } }

settings.mode:on_change(function(mode)
    apply(seed, mode) -- the pipeline above
    mantle.applications:launch("org.example.Editor", { env = { GTK_THEME = false } })
end)

The env table sets variables with a string and unsets them with false; here the relaunched app reads the files just written, not a GTK_THEME inherited from the shell. A running single-instance app keeps its old environment: see process.detach and the applications page.