#!/usr/bin/env python3 """Capture Emacs' default face attributes for theme-studio. The output is a checked-in snapshot used to seed/review theme-studio defaults. It runs `emacs -Q --batch`, loads the files that define each `defface`, stores the raw `face-default-spec`, then selects the normal GUI/light/24-bit branch in Python. This avoids opening a visible white `emacs -Q` frame while still keeping the default data reproducible. """ from __future__ import annotations import json import os import pathlib import re import subprocess import tempfile HERE = pathlib.Path(__file__).resolve().parent ROOT = HERE.parents[1] OUT = HERE / "emacs-default-faces.json" INVENTORY = HERE / "package-inventory.json" PROGRESS = pathlib.Path("/tmp/theme-studio-default-face-capture-progress.txt") SYNTAX = { "bg": ["default"], "p": ["default"], "kw": ["font-lock-keyword-face"], "bi": ["font-lock-builtin-face"], "pp": ["font-lock-preprocessor-face"], "fnd": ["font-lock-function-name-face"], "fnc": ["font-lock-function-call-face"], "dec": [], "ty": ["font-lock-type-face"], "prop": ["font-lock-property-name-face", "font-lock-property-use-face"], "con": ["font-lock-constant-face"], "num": ["font-lock-number-face"], "str": ["font-lock-string-face"], "esc": ["font-lock-escape-face"], "re": ["font-lock-regexp-face"], "doc": ["font-lock-doc-face"], "cm": ["font-lock-comment-face"], "cmd": ["font-lock-comment-delimiter-face"], "var": ["font-lock-variable-name-face", "font-lock-variable-use-face"], "op": ["font-lock-operator-face"], "punc": [ "font-lock-punctuation-face", "font-lock-bracket-face", "font-lock-delimiter-face", "font-lock-misc-punctuation-face", ], } UI = [ "cursor", "region", "hl-line", "highlight", "mode-line", "mode-line-inactive", "fringe", "line-number", "line-number-current-line", "minibuffer-prompt", "isearch", "lazy-highlight", "isearch-fail", "show-paren-match", "show-paren-mismatch", "link", "error", "warning", "success", "vertical-border", ] BUILTIN_FEATURES = [ "font-lock", "hl-line", "isearch", "paren", "button", "display-line-numbers", "shr", ] ATTRS = { ":foreground": "foreground", ":background": "background", ":weight": "weight", ":slant": "slant", ":underline": "underline", ":strike-through": "strike", ":box": "box", ":height": "height", ":inherit": "inherit", ":inverse-video": "inverseVideo", ":extend": "extend", ":distant-foreground": "distantForeground", } def x11_colors() -> dict[str, str]: colors: dict[str, str] = {} paths = [pathlib.Path("/usr/share/X11/rgb.txt")] paths.extend(pathlib.Path("/usr/share/emacs").glob("*/etc/rgb.txt")) path = next((p for p in paths if p.exists()), None) if path: for line in path.read_text(errors="ignore").splitlines(): if not line or line.startswith("!"): continue parts = line.split() if len(parts) < 4: continue try: r, g, b = [int(x) for x in parts[:3]] except ValueError: continue name = " ".join(parts[3:]).lower().replace(" ", "") colors[name] = f"#{r:02x}{g:02x}{b:02x}" return colors X11_COLORS = x11_colors() def color_hex(value: object) -> str | None: if not isinstance(value, str): return None if re.fullmatch(r"#[0-9a-fA-F]{6}", value): return value.lower() key = value.lower().replace(" ", "") if key in X11_COLORS: return X11_COLORS[key] m = re.fullmatch(r"gr[ae]y(\d{1,3})", key) if m: n = max(0, min(100, int(m.group(1)))) v = round(255 * n / 100) return f"#{v:02x}{v:02x}{v:02x}" return None def plist_to_dict(items: object) -> dict[str, object]: if isinstance(items, list) and len(items) == 1 and isinstance(items[0], list): items = items[0] if not isinstance(items, list): return {} out: dict[str, object] = {} i = 0 while i + 1 < len(items): key = items[i] val = items[i + 1] if isinstance(key, str) and key in ATTRS: out[ATTRS[key]] = normalize_value(val) elif key == ":bold" and val in (True, "t"): out["weight"] = "bold" elif key == ":italic" and val in (True, "t"): out["slant"] = "italic" i += 2 return out def normalize_value(value: object) -> object: if isinstance(value, list): as_plist = plist_to_dict(value) if as_plist: return as_plist return [normalize_value(v) for v in value] return value def condition_matches(condition: object) -> bool: if condition in (True, "t", None): return True if condition == "default": return False if isinstance(condition, dict): if "class" in condition: vals = condition["class"] or [] if "color" not in vals and "grayscale" not in vals: return False if "min-colors" in condition: vals = condition["min-colors"] or [] if vals and isinstance(vals[0], int) and vals[0] > 16777216: return False if "background" in condition: vals = condition["background"] or [] if vals and "light" not in vals: return False if "type" in condition and "tty" in (condition["type"] or []): return False return True if not isinstance(condition, list): return False for clause in condition: if not isinstance(clause, list) or not clause: continue key = clause[0] vals = clause[1:] if key == "class": if "color" not in vals and "grayscale" not in vals: return False elif key == "min-colors": if vals and isinstance(vals[0], int) and vals[0] > 16777216: return False elif key == "background": if vals and "light" not in vals: return False elif key == "type": if "tty" in vals: return False return True def choose_gui_light(default_spec: object) -> dict[str, object]: chosen: dict[str, object] = {} if not isinstance(default_spec, list): return chosen for entry in default_spec: if not isinstance(entry, list) or not entry: continue condition = entry[0] attrs = plist_to_dict(entry[1:]) if condition == "default": chosen.update(attrs) elif condition_matches(condition): chosen.update(attrs) break for key in ("foreground", "background", "distantForeground"): if key in chosen: hx = color_hex(chosen[key]) if hx: chosen[key + "Hex"] = hx return chosen def inherit_list(value: object) -> list[str]: if value in (None, False): return [] if isinstance(value, str): return [value] if isinstance(value, list): return [v for v in value if isinstance(v, str)] return [] def enrich_chosen_defaults(data: dict[str, object]) -> None: faces: dict[str, dict[str, object]] = data["faces"] for face, info in faces.items(): info["chosenGuiLight"] = choose_gui_light(info.get("default-spec")) fallback = { "foreground": "black", "foregroundHex": "#000000", "background": "white", "backgroundHex": "#ffffff", "weight": "normal", "slant": "normal", "underline": None, "strike": None, "box": None, "height": 1, } def effective(face: str, seen: set[str] | None = None) -> dict[str, object]: seen = seen or set() if face in seen: return dict(fallback) seen.add(face) info = faces.get(face, {}) own = dict(info.get("chosenGuiLight") or {}) result = dict(fallback) inherits = inherit_list(own.get("inherit")) for parent in inherits: result.update({k: v for k, v in effective(parent, seen).items() if v is not None}) result.update({k: v for k, v in own.items() if v is not None}) for key in ("foreground", "background", "distantForeground"): if key in result: hx = color_hex(result[key]) if hx: result[key + "Hex"] = hx else: result.pop(key + "Hex", None) if inherits: result["selectedInherits"] = inherits return result for face, info in faces.items(): info["effectiveGuiLight"] = effective(face) def package_dirs(pkg: str) -> list[pathlib.Path]: roots = [ROOT / "elpa", ROOT / "straight" / "build", ROOT / "site-lisp"] out: list[pathlib.Path] = [] for root in roots: if not root.exists(): continue out.extend( p for p in root.iterdir() if p.is_dir() and (p.name == pkg or p.name.startswith(pkg + "-")) ) return out def defface_files(inventory: dict[str, list[str]]) -> tuple[dict[str, dict[str, str]], dict[str, list[str]], list[str]]: found: dict[str, dict[str, str]] = {} missing: dict[str, list[str]] = {} load_paths: list[str] = [] for pkg, faces in inventory.items(): dirs = package_dirs(pkg) load_paths.extend(str(d) for d in dirs) by_face: dict[str, str] = {} pending = set(faces) for directory in dirs: for path in directory.rglob("*.el"): if not pending: break try: text = path.read_text(errors="ignore") except OSError: continue for face in list(pending): pat = r"\(\s*defface\s+" + re.escape(face) + r"\b" if re.search(pat, text): by_face[face] = str(path) pending.remove(face) if not pending: break found[pkg] = by_face if pending: missing[pkg] = sorted(pending) return found, missing, sorted(set(load_paths)) def elisp_quote(value: object) -> str: return json.dumps(value) def main() -> None: inventory = json.loads(INVENTORY.read_text()) face_files, missing, load_paths = defface_files(inventory) package_faces = sorted({face for faces in inventory.values() for face in faces}) package_files = sorted(set(face_files[pkg][face] for pkg in face_files for face in face_files[pkg])) all_faces = sorted(set(UI) | {f for faces in SYNTAX.values() for f in faces} | set(package_faces)) script = f""" (require 'json) (require 'cl-lib) (setq json-object-type 'alist) (setq json-array-type 'list) (setq json-key-type 'symbol) (defconst ts-probe-load-paths (json-read-from-string {elisp_quote(json.dumps(load_paths))})) (defconst ts-probe-builtin-features (json-read-from-string {elisp_quote(json.dumps(BUILTIN_FEATURES))})) (defconst ts-probe-package-files (json-read-from-string {elisp_quote(json.dumps(package_files))})) (defconst ts-probe-syntax-map (json-read-from-string {elisp_quote(json.dumps(SYNTAX))})) (defconst ts-probe-ui-faces (json-read-from-string {elisp_quote(json.dumps(UI))})) (defconst ts-probe-package-inventory (json-read-from-string {elisp_quote(json.dumps(inventory))})) (defconst ts-probe-package-defface-files (json-read-from-string {elisp_quote(json.dumps(face_files))})) (defconst ts-probe-package-unresolved-faces (json-read-from-string {elisp_quote(json.dumps(missing))})) (defconst ts-probe-all-faces (json-read-from-string {elisp_quote(json.dumps(all_faces))})) (dolist (dir ts-probe-load-paths) (add-to-list 'load-path dir)) (dolist (feature (mapcar #'intern ts-probe-builtin-features)) (ignore-errors (require feature))) (dolist (file ts-probe-package-files) (with-temp-file {elisp_quote(str(PROGRESS))} (insert file)) (ignore-errors (load file nil t))) (defun ts-probe--proper-list-p (value) (or (null value) (and (consp value) (ts-probe--proper-list-p (cdr value))))) (defun ts-probe--safe (value) (cond ((keywordp value) (symbol-name value)) ((symbolp value) (symbol-name value)) ((and (consp value) (ts-probe--proper-list-p value)) (vconcat (mapcar #'ts-probe--safe value))) ((consp value) (vector "cons" (ts-probe--safe (car value)) (ts-probe--safe (cdr value)))) ((vectorp value) (mapcar #'ts-probe--safe (append value nil))) (t value))) (defun ts-probe--json-bool (v) (if v t :json-false)) (defun ts-probe--attr (face attr) (let ((v (face-attribute face attr nil 'default))) (cond ((eq v 'unspecified) nil) ((eq v 'unspecified-fg) nil) ((eq v 'unspecified-bg) nil) (t v)))) (defun ts-probe--face (name) (let ((face (intern name))) (if (not (facep face)) `((exists . :json-false)) `((exists . t) (foreground . ,(ts-probe--safe (ts-probe--attr face :foreground))) (background . ,(ts-probe--safe (ts-probe--attr face :background))) (weight . ,(ts-probe--safe (ts-probe--attr face :weight))) (slant . ,(ts-probe--safe (ts-probe--attr face :slant))) (underline . ,(ts-probe--safe (ts-probe--attr face :underline))) (strike . ,(ts-probe--safe (ts-probe--attr face :strike-through))) (box . ,(ts-probe--safe (ts-probe--attr face :box))) (height . ,(ts-probe--safe (ts-probe--attr face :height))) (inherit . ,(ts-probe--safe (ts-probe--attr face :inherit))) (default-spec . ,(ts-probe--safe (face-default-spec face))))))) (let ((json-encoding-pretty-print t)) (with-temp-file {elisp_quote(str(OUT))} (insert (json-encode `((meta . ((captured-by . "scripts/theme-studio/capture-default-faces.py") (emacs-version . ,emacs-version) (resolution-model . "gui-light-24bit-from-face-default-spec") (window-system . "batch") (display-color-cells . 16777216) (default-foreground . "black") (default-background . "white") (package-face-count . ,{len(package_faces)}) (loaded-defface-file-count . ,{len(package_files)}))) (syntax-map . ,ts-probe-syntax-map) (ui-faces . ,ts-probe-ui-faces) (package-inventory . ,ts-probe-package-inventory) (package-defface-files . ,ts-probe-package-defface-files) (package-unresolved-faces . ,ts-probe-package-unresolved-faces) (faces . ,(mapcar (lambda (face) (cons (intern face) (ts-probe--face face))) ts-probe-all-faces))))))) (kill-emacs) """ with tempfile.NamedTemporaryFile("w", suffix=".el", delete=False) as f: f.write(script) probe = f.name try: subprocess.run(["emacs", "-Q", "--batch", "-l", probe], cwd=ROOT, check=True, timeout=240) finally: try: os.unlink(probe) except OSError: pass data = json.loads(OUT.read_text()) enrich_chosen_defaults(data) data["meta"]["package-unresolved-face-count"] = sum(len(v) for v in missing.values()) OUT.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") print(f"wrote {OUT}") print(json.dumps(data["meta"], indent=2, sort_keys=True)) if __name__ == "__main__": main()