Some checks failed
Check / eval (push) Has been cancelled
The machine flake's git-tracked settings file is system state, not "theme" only — rename it to state.json. CLI becomes nomarchy-state-sync with a nomarchy-theme-sync symlink for scripts and muscle memory. Eval (mkFlake, doctor, lifecycle) still accepts theme-state.json; the next write migrates to state.json and removes the legacy file. Documented in MIGRATION.md; drop the CLI alias after release notes.
187 lines
6.9 KiB
Python
187 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Convert old-distro palettes (colors.toml) into Nomarchy theme JSON presets.
|
|
|
|
Usage: tools/import-palettes.py <old-palettes-dir> <output-themes-dir>
|
|
|
|
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:
|
|
<slug>.json the palette (Nomarchy theme schema)
|
|
<slug>/backgrounds/ wallpapers, copied verbatim
|
|
<slug>/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.
|
|
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")
|
|
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()
|