aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/generate.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/theme-studio/generate.py')
-rw-r--r--scripts/theme-studio/generate.py241
1 files changed, 133 insertions, 108 deletions
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index c489b79cc..6baa67a91 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -2,7 +2,7 @@ import json, os, re
from app_inventory import add_inventory_apps, apply_default_face_seeds, apply_package_overrides, face_rows
from default_faces import DefaultFaces
from face_data import *
-from face_specs import face_spec, ui_face_spec
+from face_specs import face_spec, ui_face_spec, migrate_legacy
HERE=os.path.dirname(os.path.abspath(__file__))
def read_text(name):
@@ -37,6 +37,9 @@ COLORMATH_BODY=strip_exports(read_text('colormath.js'))
# (MAP_J, PALETTE_J, COLORMATH_J, ...); those are filled after it is spliced in.
STYLES=read_text('styles.css')
APP_BODY=read_text('app.js')
+# Bespoke per-package preview renderers, spliced into the page <script> via the
+# PREVIEWS_J token in app.js. No imports/exports, so read raw.
+PREVIEWS_BODY=read_text('previews.js')
# Pure package-model + dropdown logic, inlined into the page (and unit-tested via
# test-app-core.mjs) the same way colormath.js is.
APP_CORE_BODY=strip_exports(read_text('app-core.js'))
@@ -55,13 +58,21 @@ PALETTE_ACTIONS_BODY=strip_exports(read_text('palette-actions.js'))
# under the test harness while still shipping one self-contained HTML file.
BROWSER_GATES_BODY=strip_exports(read_text('browser-gates.js'))
COLOR_NAMES=read_json('color-names.json')
+# Face docstrings (first line each), dumped from a live Emacs via
+# face-docs-dump.el. Two maps: "faces" keyed by real face name (UI + package
+# tables), "syntax" keyed by theme-studio category (the syntax table). Inlined so
+# the element hovers can show each face's docstring on top of the existing title.
+_face_docs=read_json('face-docs.json')
+FACE_DOCS=_face_docs['faces']
+SYNTAX_DOCS=_face_docs['syntax']
ns={}
src=read_text('samples.py')
exec(src[:src.index('# THEME_STUDIO_DATA_END')], ns)
-SAMPLES={"Elisp":ns['ELS'],"Go":ns['GOS'],"Python":ns['PYS'],"TypeScript":ns['TSS'],"Java":ns['JAS'],"C":ns['CS'],"C++":ns['CPS'],"Rust":ns['RUSTS'],"Zig":ns['ZIGS'],"Shell":ns['SHS']}
+SAMPLES={"Elisp":ns['ELS'],"Go":ns['GOS'],"Python":ns['PYS'],"TypeScript":ns['TSS'],"Java":ns['JAS'],"C":ns['CS'],"C++":ns['CPS'],"Rust":ns['RUSTS'],"Zig":ns['ZIGS'],"Shell":ns['SHS'],
+ "Racket":ns['RACKETS'],"Scheme":ns['SCHEMES'],"Haskell":ns['HASKELLS'],"OCaml":ns['OCAMLS'],"Scala":ns['SCALAS'],"Kotlin":ns['KOTLINS'],"Swift":ns['SWIFTS'],"Lua":ns['LUAS'],"Ruby":ns['RUBYS'],"Perl":ns['PERLS'],"R":ns['RLANGS'],"Erlang":ns['ERLANGS'],"SQL":ns['SQLS'],"PHP":ns['PHPS'],"Ada":ns['ADAS'],"Fortran":ns['FORTRANS'],"MATLAB":ns['MATLABS'],"Assembly":ns['ASMS']}
COLS=ns['COLS']
DEFAULT_FACES_PATH=os.path.join(HERE,'emacs-default-faces.json')
-DEFAULTS=DefaultFaces.from_path(DEFAULT_FACES_PATH)
+
def column_id(name):
name = name or 'color'
if re.fullmatch(r'color-\d+', name):
@@ -100,19 +111,34 @@ def initial_maps(cols,defaults):
def apply_builtin_fallback_styles(uimap):
"""Fill the small set of style defaults used when no Emacs snapshot exists."""
- uimap["link"]["underline"]=True
+ uimap["link"]["underline"]={"style":"line","color":None}
for face in ("lazy-highlight","show-paren-match"):
- uimap[face]["underline"]=True
+ uimap[face]["underline"]={"style":"line","color":None}
for face in ("error","warning","success"):
- uimap[face]["bold"]=True
+ uimap[face]["weight"]="bold"
for face in ("mode-line","mode-line-inactive"):
uimap[face]["box"]={"style":"released","width":1,"color":None}
+def apply_hover_box_default(uimap):
+ """Seed the mode-line hover face's box.
+
+ `mode-line-highlight` (applied via mouse-face to the clickable mode-line
+ segments) is absent from the captured Emacs snapshot, so seed() returns
+ blank for it in both branches below. Emacs's stock default is a raised
+ released-button box; default to that so the studio reflects current
+ behavior, then let the user flatten or recolor it. A future snapshot that
+ captures the face wins (the box-already-set guard leaves it alone)."""
+ face=uimap.get("mode-line-highlight")
+ if face and not face.get("box"):
+ face["box"]={"style":"released","width":1,"color":None}
+
def build_uimap(ui_faces,defaults):
if defaults.available:
- return {face[0]:ui_face_spec(defaults.seed(face[0],False)) for face in ui_faces}
- uimap={face[0]:ui_face_spec() for face in ui_faces}
- apply_builtin_fallback_styles(uimap)
+ uimap={face[0]:ui_face_spec(defaults.seed(face[0],False)) for face in ui_faces}
+ else:
+ uimap={face[0]:ui_face_spec() for face in ui_faces}
+ apply_builtin_fallback_styles(uimap)
+ apply_hover_box_default(uimap)
return uimap
def build_syntax(cols,map_,bold,italic,defaults):
@@ -133,7 +159,7 @@ def apply_seed_basics(data,palette,uimap,locks):
palette=data['palette']
if data.get('ui'):
for key,value in data['ui'].items():
- uimap[key]=value
+ uimap[key]=migrate_legacy(value)
if 'locks' in data:
locks=data['locks']
return palette,uimap,locks
@@ -159,33 +185,28 @@ def add_palette_color(palette,defaults,value,label=None):
name=base+'-'+str(n); n+=1
palette.append([value,name,column_id(name)])
+def _harvest_spec_colors(palette,defaults,spec):
+ """Add a face spec's fg, bg, and box color (if any) to the palette, in order."""
+ add_palette_color(palette,defaults,spec.get('fg'))
+ add_palette_color(palette,defaults,spec.get('bg'))
+ if spec.get('box'):
+ add_palette_color(palette,defaults,spec['box'].get('color'))
+
def add_default_palette_colors(palette,map_,syntax,uimap,apps,defaults):
for key,value in map_.items():
add_palette_color(palette,defaults,value,'bg' if key=='bg' else 'fg' if key=='p' else None)
for spec in syntax.values():
- add_palette_color(palette,defaults,spec.get('fg'))
- add_palette_color(palette,defaults,spec.get('bg'))
- if spec.get('box'):
- add_palette_color(palette,defaults,spec['box'].get('color'))
+ _harvest_spec_colors(palette,defaults,spec)
for _face,spec in uimap.items():
- add_palette_color(palette,defaults,spec.get('fg'))
- add_palette_color(palette,defaults,spec.get('bg'))
- if spec.get('box'):
- add_palette_color(palette,defaults,spec['box'].get('color'))
+ _harvest_spec_colors(palette,defaults,spec)
for app in apps.values():
for _face,_label,spec in app['faces']:
- add_palette_color(palette,defaults,spec.get('fg'))
- add_palette_color(palette,defaults,spec.get('bg'))
- if spec.get('box'):
- add_palette_color(palette,defaults,spec['box'].get('color'))
+ _harvest_spec_colors(palette,defaults,spec)
def apply_seed_packages(apps,data,seed):
if seed:
apply_package_overrides(apps,data.get('packages'))
-MAP,BOLD,ITALIC_MAP=initial_maps(COLS,DEFAULTS)
-
-PALETTE=[[MAP['bg'],"bg","ground"],[MAP['p'],"fg","ground"]]
CATS=[["bg","bg (ground)","Aa Bb 123"],["p","fg","other / whitespace"],["kw","keyword","class def if return"],["bi","builtin","len echo printf"],
["pp","preprocessor","#include #define"],["fnd","function · def","resolve push"],
["fnc","function · call","printf rsync get"],["dec","decorator → type","@dataclass"],
@@ -197,7 +218,9 @@ CATS=[["bg","bg (ground)","Aa Bb 123"],["p","fg","other / whitespace"],["kw","ke
["punc","punctuation","{ } ( ) ;"]]
UI_FACES=[["cursor","cursor","Aa|"],["region","region (selection)","selected text"],
["hl-line","hl-line (current line)","current line"],["highlight","highlight","hover"],
- ["mode-line","mode-line","status active"],["mode-line-inactive","mode-line-inactive","status idle"],
+ ["mode-line","mode-line","status active"],
+ ["mode-line-highlight","mode-line-highlight (hover)","git:main"],
+ ["mode-line-inactive","mode-line-inactive","status idle"],
["fringe","fringe","| |"],["line-number","line-number"," 42"],
["line-number-current-line","line-number-current-line","> 42"],["minibuffer-prompt","minibuffer-prompt","M-x "],
["isearch","isearch (match)","match"],["lazy-highlight","lazy-highlight","other match"],
@@ -205,95 +228,97 @@ UI_FACES=[["cursor","cursor","Aa|"],["region","region (selection)","selected tex
["show-paren-mismatch","show-paren-mismatch",") ("],["link","link","https://"],
["error","error","error!"],["warning","warning","warning"],
["success","success","ok"],["vertical-border","vertical-border","|"]]
-UIMAP=build_uimap(UI_FACES,DEFAULTS)
-# Optional palette seed: THEME_STUDIO_SEED=<file.json> seeds the tool's starting
-# palette / syntax / UI from a theme.json (path relative to
-# this dir), instead of the hardcoded defaults above. Unset leaves them unchanged.
-# Placed after every default it overrides (notably UIMAP) so the merge has targets.
-# Mirrors what the in-page Import does, so reseed and import agree.
-LOCKS=[]
-# THEME_STUDIO_SEED=<file>.json opens an existing theme as the starting point.
-# Unset starts empty: only bg/fg are in the palette.
-_seed=os.environ.get('THEME_STUDIO_SEED')
-_d=load_seed_data(_seed)
-PALETTE,UIMAP,LOCKS=apply_seed_basics(_d,PALETTE,UIMAP,LOCKS)
-PALETTE=normalize_palette(PALETTE)
-SYNTAX=build_syntax(COLS,MAP,BOLD,ITALIC_MAP,DEFAULTS)
-apply_syntax_seed(_d if _seed else {},SYNTAX,MAP)
-# Bespoke package face lists and seed defaults live in face_data.py. Each entry
-# is (key, label, preview, FACES, prefix, SEED); add an app by adding one row.
-_BESPOKE_APPS=[
- ("org-mode","org-mode","org",ORG_FACES,"org-",ORG_SEED),
- ("magit","magit","magit",MAGIT_FACES,"magit-",MAGIT_SEED),
- ("elfeed","elfeed","elfeed",ELFEED_FACES,"elfeed-",ELFEED_SEED),
- ("mu4e","mu4e","mu4e",MU4E_FACES,"mu4e-",MU4E_SEED),
- ("gnus","gnus (mu4e article view)","gnus",GNUS_FACES,"gnus-",GNUS_SEED),
- ("org-faces","org-faces","orgfaces",ORGFACES_FACES,"org-faces-",ORGFACES_SEED),
- ("ghostel","ghostel","ghostel",GHOSTEL_FACES,"ghostel-",GHOSTEL_SEED),
- ("auto-dim-other-buffers","auto-dim","autodim",AUTODIM_FACES,"auto-dim-other-buffers-",AUTODIM_SEED),
- ("dashboard","dashboard","dashboard",DASHBOARD_FACES,"dashboard-",DASHBOARD_SEED),
- ("lsp-mode","lsp-mode","lsp",LSP_FACES,"lsp-",LSP_SEED),
- ("git-gutter","git-gutter","gitgutter",GITGUTTER_FACES,"git-gutter:",GITGUTTER_SEED),
- ("flycheck","flycheck","flycheck",FLYCHECK_FACES,"flycheck-",FLYCHECK_SEED),
- ("dired","dired","dired",DIRED_FACES,"dired-",DIRED_SEED),
- ("dirvish","dirvish","dirvish",DIRVISH_FACES,"dirvish-",DIRVISH_SEED),
- ("calibredb","calibredb","calibredb",CALIBREDB_FACES,"calibredb-",CALIBREDB_SEED),
- ("erc","erc","erc",ERC_FACES,"erc-",ERC_SEED),
- ("org-drill","org-drill","orgdrill",ORGDRILL_FACES,"org-drill-",ORGDRILL_SEED),
- ("org-noter","org-noter","orgnoter",ORGNOTER_FACES,"org-noter-",ORGNOTER_SEED),
- ("signel","signel","signel",SIGNEL_FACES,"signel-",SIGNEL_SEED),
- ("pearl","pearl","pearl",PEARL_FACES,"pearl-",PEARL_SEED),
- ("slack","slack","slack",SLACK_FACES,"slack-",SLACK_SEED),
- ("telega","telega","telega",TELEGA_FACES,"telega-",TELEGA_SEED),
- ("shr","shr (HTML: nov/eww/mail)","shr",SHR_FACES,"shr-",SHR_SEED),
-]
-APPS={key:{"label":label,"preview":preview,"faces":face_rows(faces,prefix,seed)}
- for key,label,preview,faces,prefix,seed in _BESPOKE_APPS}
-# Phase 6: merge the generated all-package inventory (refresh with build-inventory.el).
-# Bespoke apps stay; every other installed package becomes an editable generic app.
-_inv_path=os.path.join(HERE,"package-inventory.json")
-add_inventory_apps(APPS, _inv_path)
-apply_default_face_seeds(APPS, DEFAULTS)
-# 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.
-apply_seed_packages(APPS,_d,_seed)
+OUT=os.path.join(HERE,'theme-studio.html')
+_CACHE={}
-if DEFAULTS.available:
- add_default_palette_colors(PALETTE,MAP,SYNTAX,UIMAP,APPS,DEFAULTS)
+def _build():
+ """Assemble the page, caching the derived data + HTML. Deferred from import
+ so a consumer that only needs the cheap module constants (e.g.
+ face_coverage.py reading UI_FACES) does not pay the full DEFAULTS + inventory
+ + fill cost; the file write stays __main__-guarded as before."""
+ if _CACHE:
+ return _CACHE
+ DEFAULTS=DefaultFaces.from_path(DEFAULT_FACES_PATH)
+ MAP,BOLD,ITALIC_MAP=initial_maps(COLS,DEFAULTS)
+ PALETTE=[[MAP['bg'],"bg","ground"],[MAP['p'],"fg","ground"]]
+ UIMAP=build_uimap(UI_FACES,DEFAULTS)
-PALETTE=normalize_palette(PALETTE)
-HTML=read_text('theme-studio.template.html')
-# Fill the data placeholders. str.replace is literal (no backref interpretation),
-# so backslashes in the inlined JS survive intact — the escaping-bug class that
-# the triple-quoted string used to cause is gone now that app.js is a real file.
-# Caveat: these tokens are replaced everywhere they appear, including inside code
-# comments. Don't write a placeholder name (COLORMATH_J, APP_CORE_J, ...) in
-# prose in any inlined file, or that prose gets the body spliced into it too.
-def fill_data(s):
- return (s.replace("COLORMATH_J",COLORMATH_BODY)
- .replace("APP_CORE_J",APP_CORE_BODY)
- .replace("APP_UTIL_J",APP_UTIL_BODY)
- .replace("PALETTE_GENERATOR_CORE_J",PALETTE_GENERATOR_CORE_BODY)
- .replace("PALETTE_GENERATOR_UI_J",PALETTE_GENERATOR_UI_BODY)
- .replace("PALETTE_ACTIONS_J",PALETTE_ACTIONS_BODY)
- .replace("BROWSER_GATES_J",BROWSER_GATES_BODY)
- .replace("COLOR_NAMES_J",json.dumps(COLOR_NAMES))
- .replace("SAMPLES_J",json.dumps(SAMPLES))
- .replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS))
- .replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS))
- .replace("SYNTAX_J",json.dumps(SYNTAX)).replace("MAP_J",json.dumps(MAP)).replace("LOCKS_J",json.dumps(LOCKS)))
+ # Optional palette seed: THEME_STUDIO_SEED=<file.json> seeds the tool's starting
+ # palette / syntax / UI from a theme.json (path relative to
+ # this dir), instead of the hardcoded defaults above. Unset leaves them unchanged.
+ # Placed after every default it overrides (notably UIMAP) so the merge has targets.
+ # Mirrors what the in-page Import does, so reseed and import agree.
+ LOCKS=[]
+ # THEME_STUDIO_SEED=<file>.json opens an existing theme as the starting point.
+ # Unset starts empty: only bg/fg are in the palette.
+ _seed=os.environ.get('THEME_STUDIO_SEED')
+ _d=load_seed_data(_seed)
+ PALETTE,UIMAP,LOCKS=apply_seed_basics(_d,PALETTE,UIMAP,LOCKS)
+ PALETTE=normalize_palette(PALETTE)
+ SYNTAX=build_syntax(COLS,MAP,BOLD,ITALIC_MAP,DEFAULTS)
+ apply_syntax_seed(_d if _seed else {},SYNTAX,MAP)
+ # Bespoke apps are single-sourced as BESPOKE_APP_SPECS in face_data.py (one
+ # row per app: key, label, preview, FACES, prefix, SEED).
+ APPS={key:{"label":label,"preview":preview,"faces":face_rows(faces,prefix,seed)}
+ for key,label,preview,faces,prefix,seed in BESPOKE_APP_SPECS}
+ # Phase 6: merge the generated all-package inventory (refresh with build-inventory.el).
+ # Bespoke apps stay; every other installed package becomes an editable generic app.
+ _inv_path=os.path.join(HERE,"package-inventory.json")
+ add_inventory_apps(APPS, _inv_path)
+ apply_default_face_seeds(APPS, DEFAULTS)
+ # 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.
+ apply_seed_packages(APPS,_d,_seed)
-# Splice the stylesheet and script in first, then fill the data placeholders they
-# carry. The page contains app.js exactly as fill_data(APP_BODY) renders it —
-# APP_FILLED is that rendering, the handle the inline-integrity test asserts on.
-HTML=fill_data(HTML.replace("STYLES_CSS",STYLES).replace("APP_JS",APP_BODY))
-APP_FILLED=fill_data(APP_BODY)
-OUT=os.path.join(HERE,'theme-studio.html')
+ if DEFAULTS.available:
+ add_default_palette_colors(PALETTE,MAP,SYNTAX,UIMAP,APPS,DEFAULTS)
+
+ PALETTE=normalize_palette(PALETTE)
+ HTML=read_text('theme-studio.template.html')
+ # Fill the data placeholders. str.replace is literal (no backref interpretation),
+ # so backslashes in the inlined JS survive intact — the escaping-bug class that
+ # the triple-quoted string used to cause is gone now that app.js is a real file.
+ # Caveat: these tokens are replaced everywhere they appear, including inside code
+ # comments. Don't write a placeholder name (COLORMATH_J, APP_CORE_J, ...) in
+ # prose in any inlined file, or that prose gets the body spliced into it too.
+ def fill_data(s):
+ return (s.replace("COLORMATH_J",COLORMATH_BODY)
+ .replace("APP_CORE_J",APP_CORE_BODY)
+ .replace("PREVIEWS_J",PREVIEWS_BODY)
+ .replace("APP_UTIL_J",APP_UTIL_BODY)
+ .replace("PALETTE_GENERATOR_CORE_J",PALETTE_GENERATOR_CORE_BODY)
+ .replace("PALETTE_GENERATOR_UI_J",PALETTE_GENERATOR_UI_BODY)
+ .replace("PALETTE_ACTIONS_J",PALETTE_ACTIONS_BODY)
+ .replace("BROWSER_GATES_J",BROWSER_GATES_BODY)
+ .replace("COLOR_NAMES_J",json.dumps(COLOR_NAMES))
+ .replace("FACE_DOCS_J",json.dumps(FACE_DOCS)).replace("SYNTAX_DOCS_J",json.dumps(SYNTAX_DOCS))
+ .replace("SAMPLES_J",json.dumps(SAMPLES))
+ .replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS))
+ .replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS))
+ .replace("SYNTAX_J",json.dumps(SYNTAX)).replace("MAP_J",json.dumps(MAP)).replace("LOCKS_J",json.dumps(LOCKS)))
+
+ # Splice the stylesheet and script in first, then fill the data placeholders they
+ # carry. The page contains app.js exactly as fill_data(APP_BODY) renders it —
+ # APP_FILLED is that rendering, the handle the inline-integrity test asserts on.
+ HTML=fill_data(HTML.replace("STYLES_CSS",STYLES).replace("APP_JS",APP_BODY))
+ APP_FILLED=fill_data(APP_BODY)
+ _CACHE.update(DEFAULTS=DEFAULTS, MAP=MAP, BOLD=BOLD, ITALIC_MAP=ITALIC_MAP,
+ PALETTE=PALETTE, UIMAP=UIMAP, LOCKS=LOCKS, SYNTAX=SYNTAX,
+ APPS=APPS, HTML=HTML, APP_FILLED=APP_FILLED)
+ return _CACHE
+
+def __getattr__(name):
+ # PEP 562: lazily expose any built attribute (HTML, MAP, APPS, ...). Every
+ # other name is a real module global and never reaches here.
+ built = _build()
+ if name in built:
+ return built[name]
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def render_theme_studio(out_path=OUT):
with open(out_path,"w") as out:
- out.write(HTML)
+ out.write(_build()['HTML'])
print("wrote",out_path)
if __name__=='__main__':