diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-13 15:05:27 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-13 15:05:27 -0500 |
| commit | 2d8047ccf453b1248f9e9ba25d53f5f49d7a9c97 (patch) | |
| tree | 15eee28d9bdc064301b34129d19bda413f74ba0e /scripts/theme-studio | |
| parent | a090138d84f3e93b2d57b38159cf0b3b7330fe11 (diff) | |
| download | dotemacs-2d8047ccf453b1248f9e9ba25d53f5f49d7a9c97.tar.gz dotemacs-2d8047ccf453b1248f9e9ba25d53f5f49d7a9c97.zip | |
Extract theme studio default face adapter
Diffstat (limited to 'scripts/theme-studio')
| -rw-r--r-- | scripts/theme-studio/default_faces.py | 128 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 96 | ||||
| -rw-r--r-- | scripts/theme-studio/test_generate.py | 60 |
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() |
