{ pkgs }: pkgs.writeText "theme-builder.py" '' from colorsys import rgb_to_hls, hls_to_rgb from collections import Counter from pathlib import Path from PIL import Image from pynvim import attach import subprocess import math import os import sys STATE_DIR = Path(os.path.expanduser("~/.local/state/sysflake")) MULT = 2.5 GRAYSCALE = { "base00": "1a1a1a", "base01": "2a2a2a", "base02": "3a3a3a", "base03": "5a5a5a", "base04": "8a8a8a", "base05": "b0b0b0", "base06": "d0d0d0", "base07": "e8e8e8", "base08": "4a4a4a", "base09": "555555", "base0A": "606060", "base0B": "6b6b6b", "base0C": "767676", "base0D": "818181", "base0E": None, # accent slot "base0F": "8c8c8c", } KITTY_MAP = { "background": "00", "foreground": "05", "cursor": "05", "selection_background": "02", "selection_foreground": "05", "color0": "00", "color1": "08", "color2": "0E", "color3": "0A", "color4": "0D", "color5": "0E", "color6": "0E", "color7": "05", "color8": "03", "color9": "08", "color10": "0E", "color11": "0A", "color12": "0D", "color13": "0E", "color14": "0E", "color15": "07", } def extract_accent(image_path): img = Image.open(image_path).resize((200, 200)).quantize(colors=32).convert("RGB") pixels = list(img.get_flattened_data()) counts = Counter(pixels) total = len(pixels) def score(color): _, l, s = rgb_to_hls(color[0] / 255, color[1] / 255, color[2] / 255) vibrancy = s * (1 - abs(l - 0.5) * 2) frequency = counts[color] / total return vibrancy * frequency best = max(counts.keys(), key=score) h, l, s = rgb_to_hls(best[0] / 255, best[1] / 255, best[2] / 255) new_s = min(max(s * MULT, 0.6), 0.7) # Blue hues are perceptually darker — gradually boost lightness floor blue_center = 0.63 blue_width = 0.09 blue_factor = max(0, 1 - abs(h - blue_center) / blue_width) min_l = 0.45 + 0.2 * blue_factor new_l = max(min_l, min(0.7, l)) r, g, b = hls_to_rgb(h, new_l, new_s) return f"{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}" def build_scheme(accent): scheme = dict(GRAYSCALE) scheme["base0E"] = accent return scheme def write_scheme(scheme): path = STATE_DIR / "scheme.txt" with open(path, "w") as f: for key, val in scheme.items(): f.write(f"{key} {val}\n") def write_kitty(scheme): path = STATE_DIR / "kitty-colors.conf" with open(path, "w") as f: for name, base in KITTY_MAP.items(): f.write(f"{name} #{scheme['base' + base]}\n") VESKTOP_DIR = Path(os.path.expanduser("~/.config/vesktop/themes")) def hex_to_oklch(hex_color): """Convert hex color to oklch (lightness, chroma, hue) values.""" r = int(hex_color[0:2], 16) / 255 g = int(hex_color[2:4], 16) / 255 b = int(hex_color[4:6], 16) / 255 # sRGB to linear def to_linear(c): return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 lr, lg, lb = to_linear(r), to_linear(g), to_linear(b) # Linear RGB to OKLab l_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb m_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb s_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb l_ = l_ ** (1/3) if l_ >= 0 else -((-l_) ** (1/3)) m_ = m_ ** (1/3) if m_ >= 0 else -((-m_) ** (1/3)) s_ = s_ ** (1/3) if s_ >= 0 else -((-s_) ** (1/3)) L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ bv = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ C = math.sqrt(a ** 2 + bv ** 2) H = math.degrees(math.atan2(bv, a)) % 360 return L, C, H def write_vesktop(scheme): VESKTOP_DIR.mkdir(parents=True, exist_ok=True) accent = scheme["base0E"] _, chroma, hue = hex_to_oklch(accent) path = VESKTOP_DIR / "system24-dynamic.css" with open(path, "w") as f: f.write("""\ /** * system24 dynamic theme — generated by theme-engine */ @import url('https://refact0r.github.io/system24/build/system24.css'); :root { --colors: on; /* override purple (accent) with extracted color */ """) for i, lightness in enumerate([75, 70, 65, 60, 55], start=1): f.write(f" --purple-{i}: oklch({lightness}% {chroma:.4f} {hue:.1f});\n") f.write("}\n") def write_nvim(scheme): accent = scheme["base0E"] path = STATE_DIR / "nvim-colors.lua" with open(path, "w") as f: f.write("require('base16-colorscheme').setup({\n") for key, val in scheme.items(): f.write(f" {key} = '#{val}',\n") f.write("})\n\n") f.write(f"local accent = '#{accent}'\n") f.write(f"local light_gray = '#{scheme['base05']}'\n") for group in ["Statement", "Conditional", "Repeat", "Macro", "Function", "Exception", "TSMethod", "@lsp.type.method", "@lsp.type.function", "@lsp.type.macro", "DiagnosticUnderlineHint", "Boolean"]: f.write(f"vim.api.nvim_set_hl(0, '{group}', {{ fg = accent }})\n") for group in ["@lsp.type.variable", "@lsp.type.parameter", "@lsp.type.struct", "@lsp.type.class", "@lsp.type.selfTypeKeyword", "Identifier"]: f.write(f"vim.api.nvim_set_hl(0, '{group}', {{ fg = light_gray }})\n") for group in ["@lsp.type.enumMember", "@lsp.type.enum", "Number", "Integer"]: f.write(f"vim.api.nvim_set_hl(0, '{group}', {{ fg = '#{scheme['base04']}' }})\n") def write_swaync(scheme): accent = scheme["base0E"] path = STATE_DIR / "swaync-colors.css" with open(path, "w") as f: f.write(f"""\ @define-color base #{scheme['base00']}; @define-color mantle #{scheme['base01']}; @define-color crust #{scheme['base00']}; @define-color text #{scheme['base05']}; @define-color subtext0 #{scheme['base04']}; @define-color subtext1 #{scheme['base05']}; @define-color surface0 #{scheme['base01']}; @define-color surface1 #{scheme['base02']}; @define-color surface2 #{scheme['base03']}; @define-color overlay0 #{scheme['base03']}; @define-color overlay1 #{scheme['base04']}; @define-color overlay2 #{scheme['base04']}; @define-color lavender #{accent}; """) EZA_DIR = Path(os.path.expanduser("~/.config/eza")) def write_eza(scheme): EZA_DIR.mkdir(parents=True, exist_ok=True) accent = f'"#{scheme["base0E"]}"' file_color = f'"#{scheme["base05"]}"' dim = f'"#{scheme["base04"]}"' dimmer = f'"#{scheme["base03"]}"' dark = f'"#{scheme["base02"]}"' path = EZA_DIR / "theme.yml" with open(path, "w") as f: f.write(f"""\ filekinds: normal: foreground: {file_color} directory: foreground: {accent} is_bold: true is_underline: true symlink: foreground: {dim} pipe: foreground: {dimmer} block_device: foreground: {dim} char_device: foreground: {dim} socket: foreground: {dimmer} special: foreground: {dim} executable: foreground: {accent} is_bold: true mount_point: foreground: {accent} perms: user_read: foreground: {file_color} user_write: foreground: {dim} user_execute_file: foreground: {accent} user_execute_other: foreground: {accent} group_read: foreground: {dim} group_write: foreground: {dimmer} group_execute: foreground: {dim} other_read: foreground: {dimmer} other_write: foreground: {dimmer} other_execute: foreground: {dimmer} special_user_file: foreground: {accent} special_other: foreground: {dimmer} attribute: foreground: {dimmer} size: number_byte: foreground: {file_color} number_kilo: foreground: {dim} number_mega: foreground: {dim} number_giga: foreground: {accent} number_huge: foreground: {accent} unit_byte: foreground: {dimmer} unit_kilo: foreground: {dimmer} unit_mega: foreground: {dimmer} unit_giga: foreground: {dimmer} unit_huge: foreground: {dimmer} users: user_you: foreground: {file_color} user_root: foreground: {accent} user_other: foreground: {dim} group_yours: foreground: {dim} group_other: foreground: {dimmer} group_root: foreground: {accent} links: normal: foreground: {dim} multi_link_file: foreground: {accent} git: new: foreground: {accent} modified: foreground: {dim} deleted: foreground: {dimmer} renamed: foreground: {dim} typechange: foreground: {dim} ignored: foreground: {dark} conflicted: foreground: {accent} git_repo: branch_main: foreground: {file_color} branch_other: foreground: {dim} git_clean: foreground: {accent} git_dirty: foreground: {dim} file_type: image: foreground: {dim} video: foreground: {dim} music: foreground: {dim} lossless: foreground: {dim} crypto: foreground: {dimmer} document: foreground: {file_color} compressed: foreground: {dimmer} temp: foreground: {dark} compiled: foreground: {dimmer} build: foreground: {dimmer} source: foreground: {file_color} punctuation: foreground: {dimmer} date: foreground: {dim} inode: foreground: {dimmer} blocks: foreground: {dimmer} header: foreground: {file_color} octal: foreground: {dimmer} flags: foreground: {dim} symlink_path: foreground: {dim} control_char: foreground: {dimmer} broken_symlink: foreground: {accent} broken_path_overlay: foreground: {dark} """) def write_waybar(scheme): accent = scheme["base0E"] bg = scheme["base00"] fg = scheme["base05"] path = STATE_DIR / "waybar-colors.css" with open(path, "w") as f: f.write(f"@define-color accent #{accent};\n") f.write(f"@define-color bg-dark #{bg};\n") f.write(f"@define-color fg-text #{fg};\n") def write_hyprland(scheme): accent = scheme["base0E"] inactive = scheme["base03"] path = STATE_DIR / "hyprland-colors.conf" with open(path, "w") as f: f.write(f"general:col.active_border = rgba({accent}ff)\n") f.write(f"general:col.inactive_border = rgba({inactive}ff)\n") def reload_apps(): print("Reloading applications with new theme...") subprocess.run(["pkill", "-SIGUSR1", "kitty"], capture_output=True) print("Sent reload signal to Kitty.") nvim_colors = STATE_DIR / "nvim-colors.lua" for sock in Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")).glob("nvim.*.0"): try: nvim = attach('socket', path=str(sock)) nvim.command(f"luafile {nvim_colors}") print(f"Sent reload command to Neovim instance at {sock}.") except Exception as e: print(f"Failed to connect to Neovim instance at {sock}: {e}") # Live-update hyprland borders scheme = {} for line in open(STATE_DIR / "scheme.txt"): k, v = line.split() scheme[k] = v subprocess.run(["hyprctl", "keyword", "general:col.active_border", f"rgba({scheme['base0E']}ff)"], capture_output=True) subprocess.run(["hyprctl", "keyword", "general:col.inactive_border", f"rgba({scheme['base03']}ff)"], capture_output=True) print("Updated Hyprland border colors.") # Reload swaync styles subprocess.run(["swaync-client", "-rs"], capture_output=True) print("Sent reload signal to Swaync.") # Reload waybar subprocess.run(["pkill", "-SIGUSR2", "waybar"], capture_output=True) print("Sent reload signal to Waybar.") def main(): if len(sys.argv) < 2: print("Usage: theme-engine.py ", file=sys.stderr) sys.exit(1) STATE_DIR.mkdir(parents=True, exist_ok=True) accent = extract_accent(sys.argv[1]) scheme = build_scheme(accent) print(f"Extracted accent color: #{accent}") write_scheme(scheme) print("Scheme written to state directory.") write_kitty(scheme) print("Kitty config written.") write_vesktop(scheme) print("Vesktop theme written.") write_nvim(scheme) print("Neovim colors written.") write_hyprland(scheme) print("Hyprland colors written.") write_swaync(scheme) print("Swaync colors written.") write_eza(scheme) print("Eza theme written.") write_waybar(scheme) print("Waybar colors written.") reload_apps() print("Sent reload signals to applications.") print(f"Accent: #{accent}") print(f"Scheme written to {STATE_DIR / 'scheme.txt'}") print(f"Kitty config written to {STATE_DIR / 'kitty-colors.conf'}") print(f"Vesktop theme written to {VESKTOP_DIR / 'system24-dynamic.css'}") print(f"Neovim colors written to {STATE_DIR / 'nvim-colors.lua'}") print(f"Hyprland colors written to {STATE_DIR / 'hyprland-colors.conf'}") print(f"Swaync colors written to {STATE_DIR / 'swaync-colors.css'}") print(f"Eza theme written to {EZA_DIR / 'theme.yml'}") print(f"Waybar colors written to {STATE_DIR / 'waybar-colors.css'}") if __name__ == "__main__": main() ''