aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-13 15:05:27 -0500
committerCraig Jennings <c@cjennings.net>2026-06-13 15:05:27 -0500
commit2d8047ccf453b1248f9e9ba25d53f5f49d7a9c97 (patch)
tree15eee28d9bdc064301b34129d19bda413f74ba0e /scripts
parenta090138d84f3e93b2d57b38159cf0b3b7330fe11 (diff)
downloaddotemacs-2d8047ccf453b1248f9e9ba25d53f5f49d7a9c97.tar.gz
dotemacs-2d8047ccf453b1248f9e9ba25d53f5f49d7a9c97.zip
Extract theme studio default face adapter
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/default_faces.py128
-rw-r--r--scripts/theme-studio/generate.py96
-rw-r--r--scripts/theme-studio/test_generate.py60
3 files changed, 203 insertions, 81 deletions
diff --git a/scripts/theme-studio/default_faces.py b/scripts/theme-studio/default_faces.py
new file mode 100644
index 00000000..a2fd2720
--- /dev/null
+++ b/scripts/theme-studio/default_faces.py
@@ -0,0 +1,128 @@
+"""Helpers for theme-studio's captured Emacs default face snapshot."""
+
+from __future__ import annotations
+
+import json
+import pathlib
+from typing import Any
+
+
+class DefaultFaces:
+ def __init__(self, data: dict[str, Any] | None):
+ self.data = data
+ self.color_hex = self._build_color_hex()
+ self.color_names = self._build_color_names()
+
+ @classmethod
+ def from_path(cls, path: str | pathlib.Path) -> "DefaultFaces":
+ path = pathlib.Path(path)
+ if not path.exists():
+ return cls(None)
+ return cls(json.loads(path.read_text()))
+
+ @property
+ def available(self) -> bool:
+ return bool(self.data)
+
+ def face(self, face: str, effective: bool = True) -> dict[str, Any]:
+ if not self.data:
+ return {}
+ data = self.data.get("faces", {}).get(face, {})
+ block = "effectiveGuiLight" if effective else "chosenGuiLight"
+ return data.get(block, {}) or {}
+
+ def color(self, face: str, attr: str = "foreground", effective: bool = True) -> Any:
+ data = self.face(face, effective)
+ return data.get(attr + "Hex") or data.get(attr)
+
+ def seed(self, face: str, effective: bool = False) -> dict[str, Any]:
+ data = self.face(face, effective)
+ out: dict[str, Any] = {}
+ fg = data.get("foregroundHex") or data.get("foreground")
+ bg = data.get("backgroundHex") or data.get("background")
+ if fg:
+ out["fg"] = fg
+ if bg:
+ out["bg"] = bg
+ if data.get("weight") == "bold":
+ out["bold"] = True
+ if data.get("slant") == "italic":
+ out["italic"] = True
+ if data.get("underline"):
+ out["underline"] = True
+ if data.get("strike"):
+ out["strike"] = True
+ if data.get("inherit"):
+ out["inherit"] = data.get("inherit")
+ if data.get("height") and data.get("height") != 1:
+ out["height"] = data.get("height")
+ box = self.box_to_theme(data.get("box"))
+ if box:
+ out["box"] = box
+ return out
+
+ def box_to_theme(self, box: Any) -> dict[str, Any] | None:
+ if not box:
+ return None
+ if isinstance(box, dict):
+ return box
+ if not isinstance(box, list):
+ return None
+
+ vals = {}
+ i = 0
+ while i + 1 < len(box):
+ vals[box[i]] = box[i + 1]
+ i += 2
+
+ width = vals.get(":line-width", 1)
+ if isinstance(width, list) and width and width[0] == "cons":
+ width = width[1]
+ if isinstance(width, (int, float)):
+ width = abs(int(width)) or 1
+ else:
+ width = 1
+
+ color = vals.get(":color")
+ if color:
+ color = self.color_hex.get(str(color).lower().replace(" ", ""), color)
+
+ style = vals.get(":style")
+ if style == "released-button":
+ return {"style": "released", "width": width, "color": None}
+ if style == "pressed-button":
+ return {"style": "pressed", "width": width, "color": None}
+ return {"style": "line", "width": width, "color": color}
+
+ def label(self, value: str | None, fallback: str) -> str:
+ if not value:
+ return fallback
+ return self.color_names.get(str(value).lower(), fallback)
+
+ def _build_color_hex(self) -> dict[str, str]:
+ out: dict[str, str] = {}
+ if not self.data:
+ return out
+ for data in self.data.get("faces", {}).values():
+ for block in ("chosenGuiLight", "effectiveGuiLight"):
+ face_data = data.get(block, {}) or {}
+ for attr in ("foreground", "background", "distantForeground"):
+ name = face_data.get(attr)
+ hex_value = face_data.get(attr + "Hex")
+ if name and hex_value:
+ out[str(name).lower().replace(" ", "")] = hex_value
+ return out
+
+ def _build_color_names(self) -> dict[str, str]:
+ out: dict[str, str] = {}
+ if not self.data:
+ return out
+ for data in self.data.get("faces", {}).values():
+ for block in ("chosenGuiLight", "effectiveGuiLight"):
+ face_data = data.get(block, {}) or {}
+ for attr in ("foreground", "background", "distantForeground"):
+ hex_value = face_data.get(attr + "Hex")
+ name = face_data.get(attr)
+ if hex_value and name and not str(name).startswith("#"):
+ out.setdefault(hex_value.lower(), str(name).lower().replace(" ", "-"))
+ return out
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index e98d0bf3..be526242 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -1,4 +1,5 @@
import json, os, re
+from default_faces import DefaultFaces
HERE=os.path.dirname(os.path.abspath(__file__))
def strip_exports(src):
@@ -39,15 +40,7 @@ exec(src[:src.index('cols=')], ns)
SAMPLES={"Elisp":ns['ELS'],"Go":ns['GOS'],"Python":ns['PYS'],"TypeScript":ns['TSS'],"Java":ns['JAS'],"C":ns['CS'],"C++":ns['CPS'],"Shell":ns['SHS']}
COLS=ns['COLS']
DEFAULT_FACES_PATH=os.path.join(HERE,'emacs-default-faces.json')
-DEFAULT_FACES=json.load(open(DEFAULT_FACES_PATH)) if os.path.exists(DEFAULT_FACES_PATH) else None
-DEFAULT_COLOR_HEX={}
-if DEFAULT_FACES:
- for _data in DEFAULT_FACES.get('faces',{}).values():
- for _block in ('chosenGuiLight','effectiveGuiLight'):
- _d=_data.get(_block,{}) or {}
- for _attr in ('foreground','background','distantForeground'):
- if _d.get(_attr) and _d.get(_attr+'Hex'):
- DEFAULT_COLOR_HEX[str(_d[_attr]).lower().replace(' ','')]=_d[_attr+'Hex']
+DEFAULTS=DefaultFaces.from_path(DEFAULT_FACES_PATH)
MAP={k:'' for k in COLS}; MAP['bg']='#000000'; MAP['p']='#ffffff'
BOLD={k:False for k in COLS}
ITALIC_MAP={k:False for k in COLS}
@@ -58,75 +51,16 @@ def normalize_palette(palette):
return [[p[0], p[1] if len(p) > 1 else 'color', p[2] if len(p) > 2 else column_id(p[1] if len(p) > 1 else 'color')]
for p in palette]
-def default_face(face, effective=True):
- if not DEFAULT_FACES: return {}
- data=DEFAULT_FACES.get('faces',{}).get(face,{})
- return data.get('effectiveGuiLight' if effective else 'chosenGuiLight',{}) or {}
-
-def default_color(face, attr='foreground', effective=True):
- d=default_face(face,effective)
- return d.get(attr+'Hex') or d.get(attr)
-
-def emacs_box_to_theme(box):
- if not box: return None
- if isinstance(box,dict): return box
- if not isinstance(box,list): return None
- vals={}
- i=0
- while i+1<len(box):
- vals[box[i]]=box[i+1]; i+=2
- width=vals.get(':line-width',1)
- if isinstance(width,list) and width and width[0]=='cons': width=width[1]
- if isinstance(width,(int,float)): width=abs(int(width)) or 1
- else: width=1
- style=vals.get(':style')
- color=vals.get(':color')
- if color:
- color=DEFAULT_COLOR_HEX.get(str(color).lower().replace(' ',''),color)
- if style=='released-button': return {"style":"released","width":width,"color":None}
- if style=='pressed-button': return {"style":"pressed","width":width,"color":None}
- return {"style":"line","width":width,"color":color}
-
-def face_seed(face, effective=False):
- d=default_face(face,effective)
- out={}
- fg=d.get('foregroundHex') or d.get('foreground')
- bg=d.get('backgroundHex') or d.get('background')
- if fg: out['fg']=fg
- if bg: out['bg']=bg
- if d.get('weight')=='bold': out['bold']=True
- if d.get('slant')=='italic': out['italic']=True
- if d.get('underline'): out['underline']=True
- if d.get('strike'): out['strike']=True
- if d.get('inherit'): out['inherit']=d.get('inherit')
- if d.get('height') and d.get('height')!=1: out['height']=d.get('height')
- box=emacs_box_to_theme(d.get('box'))
- if box: out['box']=box
- return out
-
-def color_label(value, fallback):
- if not value: return fallback
- names={}
- if DEFAULT_FACES:
- for face,data in DEFAULT_FACES.get('faces',{}).items():
- for block in ('chosenGuiLight','effectiveGuiLight'):
- d=data.get(block,{}) or {}
- for attr in ('foreground','background','distantForeground'):
- hx=d.get(attr+'Hex')
- nm=d.get(attr)
- if hx and nm and not str(nm).startswith('#'): names.setdefault(hx.lower(), str(nm).lower().replace(' ','-'))
- return names.get(str(value).lower(), fallback)
-
-if DEFAULT_FACES:
- MAP['bg']=default_color('default','background') or MAP['bg']
- MAP['p']=default_color('default','foreground') or MAP['p']
- for cat,faces in DEFAULT_FACES.get('syntax-map',{}).items():
+if DEFAULTS.available:
+ MAP['bg']=DEFAULTS.color('default','background') or MAP['bg']
+ MAP['p']=DEFAULTS.color('default','foreground') or MAP['p']
+ for cat,faces in DEFAULTS.data.get('syntax-map',{}).items():
faces=faces or []
if cat in ('bg','p') or not faces: continue
face=faces[0]
- c=default_color(face,'foreground')
+ c=DEFAULTS.color(face,'foreground')
if c: MAP[cat]=c
- eff=default_face(face,True)
+ eff=DEFAULTS.face(face,True)
BOLD[cat]=eff.get('weight')=='bold'
ITALIC_MAP[cat]=eff.get('slant')=='italic'
else:
@@ -154,8 +88,8 @@ UI_FACES=[["cursor","cursor","Aa|"],["region","region (selection)","selected tex
["error","error","error!"],["warning","warning","warning"],
["success","success","ok"],["vertical-border","vertical-border","|"]]
UIMAP={f[0]:{"fg":None,"bg":None,"bold":False,"italic":False,"underline":False,"strike":False} for f in UI_FACES}
-if DEFAULT_FACES:
- UIMAP={f[0]:dict({"fg":None,"bg":None,"bold":False,"italic":False,"underline":False,"strike":False},**face_seed(f[0],False)) for f in UI_FACES}
+if DEFAULTS.available:
+ UIMAP={f[0]:dict({"fg":None,"bg":None,"bold":False,"italic":False,"underline":False,"strike":False},**DEFAULTS.seed(f[0],False)) for f in UI_FACES}
# Optional palette seed: THEME_STUDIO_SEED=<file.json> seeds the tool's starting
# palette / assignments / bold / italic / UI from a theme.json (path relative to
@@ -177,7 +111,7 @@ if _seed:
for _k,_v in _d['ui'].items(): UIMAP[_k]=_v
if 'locks' in _d: LOCKS=_d['locks']
PALETTE=normalize_palette(PALETTE)
-if not DEFAULT_FACES:
+if not DEFAULTS.available:
# These faces carry a fixed style in Emacs's built-in definitions. Fallback
# only; normal generation uses emacs-default-faces.json above.
UIMAP["link"]["underline"]=True
@@ -516,10 +450,10 @@ if os.path.exists(_inv_path):
APPS[_pkg]={"label":_pkg,"preview":"generic","faces":[
[f,(f[len(_pkg)+1:] if f.startswith(_pkg+"-") else f).replace("-face","").replace("-"," "),{}]
for f in _INV[_pkg]]}
-if DEFAULT_FACES:
+if DEFAULTS.available:
for _app in APPS.values():
for _row in _app["faces"]:
- _row[2]=face_seed(_row[0],False)
+ _row[2]=DEFAULTS.seed(_row[0],False)
# Apply seed theme package overrides when THEME_STUDIO_SEED is set: each full
# per-face spec (color + structure) replaces the hardcoded face seed before render.
if _seed and _d.get('packages'):
@@ -531,7 +465,7 @@ if _seed and _d.get('packages'):
def add_palette_color(value, label=None):
if not value: return
if any((p[0] or '').lower()==str(value).lower() for p in PALETTE): return
- name=label or color_label(value,'color-'+str(len(PALETTE)))
+ name=label or DEFAULTS.label(value,'color-'+str(len(PALETTE)))
base=name
n=2
used={p[1].lower() for p in PALETTE}
@@ -539,7 +473,7 @@ def add_palette_color(value, label=None):
name=base+'-'+str(n); n+=1
PALETTE.append([value,name,column_id(name)])
-if DEFAULT_FACES:
+if DEFAULTS.available:
for _k,_v in MAP.items():
add_palette_color(_v, 'bg' if _k=='bg' else 'fg' if _k=='p' else None)
for _face,_spec in UIMAP.items():
diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py
index ee13f8de..4f35e3a7 100644
--- a/scripts/theme-studio/test_generate.py
+++ b/scripts/theme-studio/test_generate.py
@@ -12,6 +12,7 @@ import os
import unittest
import generate # importable without side effects: the file write is __main__-guarded
+from default_faces import DefaultFaces
class StripExports(unittest.TestCase):
@@ -137,5 +138,64 @@ class FacesHelper(unittest.TestCase):
self.assertEqual(generate._faces([], "org-", {"org-todo": {"fg": "gold"}}), [])
+class DefaultFaceAdapter(unittest.TestCase):
+ def setUp(self):
+ self.defaults = DefaultFaces({
+ "faces": {
+ "sample": {
+ "chosenGuiLight": {
+ "foreground": "gray20",
+ "foregroundHex": "#333333",
+ "background": "white",
+ "backgroundHex": "#ffffff",
+ "weight": "bold",
+ "slant": "italic",
+ "underline": True,
+ "inherit": "parent",
+ "box": [":line-width", ["cons", 2, 2], ":style", "released-button"],
+ },
+ "effectiveGuiLight": {"foreground": "black", "foregroundHex": "#000000"},
+ },
+ "boxed": {
+ "chosenGuiLight": {
+ "box": [":line-width", -3, ":color", "gray20"],
+ },
+ "effectiveGuiLight": {},
+ },
+ }
+ })
+
+ def test_seed_uses_own_face_attributes_and_converts_boxes(self):
+ self.assertEqual(self.defaults.seed("sample", effective=False), {
+ "fg": "#333333",
+ "bg": "#ffffff",
+ "bold": True,
+ "italic": True,
+ "underline": True,
+ "inherit": "parent",
+ "box": {"style": "released", "width": 2, "color": None},
+ })
+
+ def test_color_reads_effective_hex_by_default(self):
+ self.assertEqual(self.defaults.color("sample"), "#000000")
+
+ def test_line_box_keeps_width_and_resolves_named_color(self):
+ self.assertEqual(self.defaults.seed("boxed")["box"], {
+ "style": "line",
+ "width": 3,
+ "color": "#333333",
+ })
+
+ def test_label_uses_captured_color_name_when_present(self):
+ self.assertEqual(self.defaults.label("#333333", "fallback"), "gray20")
+
+ def test_missing_snapshot_is_safe(self):
+ defaults = DefaultFaces(None)
+ self.assertFalse(defaults.available)
+ self.assertEqual(defaults.face("missing"), {})
+ self.assertEqual(defaults.seed("missing"), {})
+ self.assertEqual(defaults.label("#000000", "fallback"), "fallback")
+
+
if __name__ == "__main__":
unittest.main()