#!/usr/bin/env python3 """Convert old-distro palettes (colors.toml) into Nomarchy theme JSON presets. Usage: tools/import-palettes.py Old format (per theme directory): colors.toml kitty-style keys: background, foreground, accent, selection_background, color0..color15 light.mode marker file: theme is light icons.theme icon theme name (informational, ignored for now) backgrounds/ wallpapers apps/ hand-made per-app theme files (btop.theme, ...) Output, per theme: .json the palette (Nomarchy theme schema) /backgrounds/ wallpapers, copied verbatim — NOTE: this repo's own themes/ no longer carries backgrounds/ (moved to the Nomarchy-Wallpapers repo, ROADMAP § "Faster switches"); if is this repo's themes/, move the copied backgrounds/ over there instead of committing them here /btop.theme copied from apps/ when present Hierarchy vs ANSI (#70): Nomarchy roles (surface/overlay/mantle/…) are first-class and may diverge from ansi[0]/ansi[8]. When color0==color8 (gruvbox, everforest, many light themes' black==bright-black), do NOT collapse surface and overlay — derive a raised step so the bg stack has hierarchy. Light themes: ANSI color0 is almost always "black" (dark), not a UI chip. Using it as surface paints sludge on a light canvas — detect that trap and derive surface/overlay from base instead. After import, hand-tune roles if needed. Do not bulk-reimport shipped themes/*.json without a hierarchy pass — this tool is for *future* imports / new palettes. """ import json import shutil import sys import tomllib from pathlib import Path def darken(hex_color: str, factor: float = 0.85) -> str: """Scale a #rrggbb color toward black (used to derive 'mantle' / light chips).""" h = hex_color.lstrip("#") r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4)) return "#{:02x}{:02x}{:02x}".format(*(round(v * factor) for v in (r, g, b))) def lighten(hex_color: str, amount: float = 0.14) -> str: """Move each channel toward 255 by `amount` (0..1) — raised dark-mode chip.""" h = hex_color.lstrip("#") r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4)) return "#{:02x}{:02x}{:02x}".format( *(max(0, min(255, round(v + (255 - v) * amount))) for v in (r, g, b)) ) def _norm(hex_color: str) -> str: return hex_color.strip().lower() def _luma(hex_color: str) -> float: """Relative luminance (sRGB), for light-theme ANSI-black detection.""" h = hex_color.lstrip("#") r, g, b = (int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) def lin(c: float) -> float: return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) def hierarchy_roles(base: str, color0: str, color8: str, mode: str) -> dict: """Map ANSI → surface/overlay/mantle without collapsing when 0==8. Roles stay independent of the ANSI table: even when color0==color8 in the source palette, surface and overlay must differ so chips/menus can step up the stack. """ # mantle: step "under" the canvas. Light bases use a gentler darken so # cream/off-white does not become muddy grey sludge. mantle = darken(base, 0.85 if mode == "dark" else 0.92) surface, overlay = color0, color8 if mode == "light": # Light-theme trap: color0/color8 are ANSI black / bright-black # (dark), not raised UI chips. Derive chips off base instead. base_L = _luma(base) if _luma(surface) < base_L * 0.5: surface = darken(base, 0.94) if _luma(overlay) < base_L * 0.5: overlay = darken(base, 0.88) # When ANSI 0==8 (or the light-theme derive left them equal), force a # hierarchy step: dark mode lightens overlay; light mode darkens it. if _norm(surface) == _norm(overlay): if mode == "dark": overlay = lighten(surface, 0.14) else: overlay = darken(surface, 0.88) print(f" note: color0==color8 — derived overlay hierarchy ({surface} → {overlay})") return {"mantle": mantle, "surface": surface, "overlay": overlay} def convert(theme_dir: Path) -> dict: toml = tomllib.loads((theme_dir / "colors.toml").read_text()) slug = theme_dir.name ansi = [toml[f"color{i}"] for i in range(16)] mode = "light" if (theme_dir / "light.mode").exists() else "dark" base = toml["background"] roles = hierarchy_roles(base, toml["color0"], toml["color8"], mode) # muted: prefer color8 when it is a mid grey distinct from surface; # otherwise derive so dim labels still read (roles ≠ ANSI when 0==8). muted = toml["color8"] if _norm(muted) == _norm(roles["surface"]) or ( mode == "light" and _luma(muted) < _luma(base) * 0.5 ): if mode == "dark": muted = darken(toml["foreground"], 0.55) else: # mid grey between text and base th, bh = toml["foreground"].lstrip("#"), base.lstrip("#") muted = "#{:02x}{:02x}{:02x}".format( *((int(th[i:i + 2], 16) + int(bh[i:i + 2], 16)) // 2 for i in (0, 2, 4)) ) return { "version": 1, "name": slug.replace("-", " ").title(), "slug": slug, "mode": mode, # Empty = auto: nomarchy-state-sync falls back to the first file in # the theme's backgrounds/ directory. "wallpaper": "", "colors": { "base": base, "mantle": roles["mantle"], "surface": roles["surface"], "overlay": roles["overlay"], "text": toml["foreground"], "subtext": toml["color7"], "muted": muted, "accent": toml["accent"], "accentAlt": toml["color5"], "good": toml["color2"], "warn": toml["color3"], "bad": toml["color1"], }, "ansi": ansi, } def main() -> None: if len(sys.argv) != 3: sys.exit(__doc__) src, out = Path(sys.argv[1]), Path(sys.argv[2]) out.mkdir(parents=True, exist_ok=True) converted = 0 for theme_dir in sorted(src.iterdir()): if not (theme_dir / "colors.toml").is_file(): continue theme = convert(theme_dir) slug = theme["slug"] (out / f"{slug}.json").write_text(json.dumps(theme, indent=2) + "\n") # Assets: wallpapers and hand-made per-app theme files. Wallpapers # land in //backgrounds/ here for convenience, but if # is this repo's themes/, move them to the Nomarchy-Wallpapers # repo instead of committing them (ROADMAP § "Faster switches") — # this repo's themes/*/backgrounds/ was removed on purpose. extras = [] if (theme_dir / "backgrounds").is_dir(): shutil.copytree(theme_dir / "backgrounds", out / slug / "backgrounds", dirs_exist_ok=True) extras.append(f"{len(list((theme_dir / 'backgrounds').iterdir()))} wallpapers — move to Nomarchy-Wallpapers") if (theme_dir / "apps" / "btop.theme").is_file(): (out / slug).mkdir(parents=True, exist_ok=True) shutil.copyfile(theme_dir / "apps" / "btop.theme", out / slug / "btop.theme") extras.append("btop") print(f" {slug:<20} ({theme['mode']}{', ' + ', '.join(extras) if extras else ''})") converted += 1 print(f"converted {converted} palettes -> {out}") if __name__ == "__main__": main()