diff options
Diffstat (limited to 'scripts/theme-studio/generate.py')
| -rw-r--r-- | scripts/theme-studio/generate.py | 215 |
1 files changed, 215 insertions, 0 deletions
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py new file mode 100644 index 00000000..751dff71 --- /dev/null +++ b/scripts/theme-studio/generate.py @@ -0,0 +1,215 @@ +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 ui_face_spec +HERE=os.path.dirname(os.path.abspath(__file__)) + +def strip_exports(src): + """Drop ES-module `export`/`import` lines so the body loads as a classic <script>. + + A top-level `export` (or `import`) is a syntax error outside a module, so it + must go before the body is spliced into the page. Imports are stripped too so a + pure module may import a peer for its own unit tests (e.g. app-util.js imports + rl from colormath.js) while the inlined copy relies on the peer already being + in the page. The .mjs inline-integrity tests apply the identical strip and + assert the page carries the result verbatim, so the two copies cannot drift. + NOTE: this is line-based — each export/import statement must stay on a single + line or the continuation lines survive. + """ + return '\n'.join(l for l in src.splitlines() + if not (l.startswith('export') or l.startswith('import'))).rstrip() + +# Pure color-math core, inlined verbatim into the page so the browser runs the +# same code the Node tests import (one source of truth). +COLORMATH_BODY=strip_exports(open(os.path.join(HERE,'colormath.js')).read()) +# The app's stylesheet and script, kept as real files so they get JS/CSS tooling +# (highlight, brace-check, lint) and so the logic is unit-testable. They are +# inlined into the page the same way colormath.js is: a placeholder in the +# template, filled at generate time. app.js carries the data placeholders +# (MAP_J, PALETTE_J, COLORMATH_J, ...); those are filled after it is spliced in. +STYLES=open(os.path.join(HERE,'styles.css')).read() +APP_BODY=open(os.path.join(HERE,'app.js')).read() +# 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(open(os.path.join(HERE,'app-core.js')).read()) +# Pure color/UI-boundary helpers (normHex/ratingColor/textOn), unit-tested via +# test-app-util.mjs. Its `import rl` line is stripped on inline (rl is already in +# the page from the colormath core). +APP_UTIL_BODY=strip_exports(open(os.path.join(HERE,'app-util.js')).read()) +# Palette panel actions and rendering. This is stateful browser code, split from +# app.js because color-column behavior changes often and benefits from locality. +PALETTE_ACTIONS_BODY=strip_exports(open(os.path.join(HERE,'palette-actions.js')).read()) +# Browser hash gates, split from app.js so the application code is not buried +# under the test harness while still shipping one self-contained HTML file. +BROWSER_GATES_BODY=strip_exports(open(os.path.join(HERE,'browser-gates.js')).read()) +ns={} +src=open(os.path.join(HERE,'samples.py')).read() +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'],"Rust":ns['RUSTS'],"Zig":ns['ZIGS'],"Shell":ns['SHS']} +COLS=ns['COLS'] +DEFAULT_FACES_PATH=os.path.join(HERE,'emacs-default-faces.json') +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} +def column_id(name): + name = name or 'color' + if re.fullmatch(r'color-\d+', name): + return name + name = re.sub(r'[+-]\d+$', '', name) + return re.sub(r'\d+$', '', name) or 'color' + +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] + +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=DEFAULTS.color(face,'foreground') + if c: MAP[cat]=c + eff=DEFAULTS.face(face,True) + BOLD[cat]=eff.get('weight')=='bold' + ITALIC_MAP[cat]=eff.get('slant')=='italic' +else: + BOLD={k:v[1] for k,v in COLS.items()} + ITALIC_MAP={k:False for k in COLS} + +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","@dataclass"], + ["ty","type / class","int str Order Queue"],["prop","property / field","id name items"], + ["con","constant","None nil NULL true"],["num","number","8080 100 -1"], + ["str","string",'"dupre" "fmt"'],["esc","escape","\\n \\t"],["re","regexp","/^#[0-9a-f]+/"], + ["doc","docstring",'"""..."""'],["cm","comment","# reject nil"],["cmd","comment delim","# // ;;"], + ["var","variable / use","value key self"],["op","operator",": = -> =="], + ["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"], + ["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"], + ["isearch-fail","isearch-fail","no match"],["show-paren-match","show-paren-match","( )"], + ["show-paren-mismatch","show-paren-mismatch",") ("],["link","link","https://"], + ["error","error","error!"],["warning","warning","warning"], + ["success","success","ok"],["vertical-border","vertical-border","|"]] +UIMAP={f[0]:ui_face_spec() for f in UI_FACES} +if DEFAULTS.available: + UIMAP={f[0]:ui_face_spec(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 +# 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=[]; ITALIC=[k for k,v in ITALIC_MAP.items() if v] +# 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={} +if _seed: + _d=json.load(open(os.path.join(HERE,_seed))) + if _d.get('palette'): PALETTE=_d['palette'] + if _d.get('assignments'): MAP.update(_d['assignments']) + if 'bold' in _d: BOLD={k:(k in _d['bold']) for k in BOLD} + if 'italic' in _d: ITALIC=_d['italic'] + if _d.get('ui'): + for _k,_v in _d['ui'].items(): UIMAP[_k]=_v + if 'locks' in _d: LOCKS=_d['locks'] +PALETTE=normalize_palette(PALETTE) +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 + for _f in ("lazy-highlight","show-paren-match"): UIMAP[_f]["underline"]=True + for _f in ("error","warning","success"): UIMAP[_f]["bold"]=True + for _f in ("mode-line","mode-line-inactive"): UIMAP[_f]["box"]={"style":"released","width":1,"color":None} +# Bespoke package face lists and seed defaults live in face_data.py. +APPS={"org-mode":{"label":"org-mode","preview":"org","faces":face_rows(ORG_FACES,"org-",ORG_SEED)}, + "magit":{"label":"magit","preview":"magit","faces":face_rows(MAGIT_FACES,"magit-",MAGIT_SEED)}, + "elfeed":{"label":"elfeed","preview":"elfeed","faces":face_rows(ELFEED_FACES,"elfeed-",ELFEED_SEED)}, + "mu4e":{"label":"mu4e","preview":"mu4e","faces":face_rows(MU4E_FACES,"mu4e-",MU4E_SEED)}, + "ghostel":{"label":"ghostel","preview":"ghostel","faces":face_rows(GHOSTEL_FACES,"ghostel-",GHOSTEL_SEED)}, + "dashboard":{"label":"dashboard","preview":"dashboard","faces":face_rows(DASHBOARD_FACES,"dashboard-",DASHBOARD_SEED)}, + "lsp-mode":{"label":"lsp-mode","preview":"lsp","faces":face_rows(LSP_FACES,"lsp-",LSP_SEED)}, + "git-gutter":{"label":"git-gutter","preview":"gitgutter","faces":face_rows(GITGUTTER_FACES,"git-gutter:",GITGUTTER_SEED)}, + "flycheck":{"label":"flycheck","preview":"flycheck","faces":face_rows(FLYCHECK_FACES,"flycheck-",FLYCHECK_SEED)}, + "dired":{"label":"dired","preview":"dired","faces":face_rows(DIRED_FACES,"dired-",DIRED_SEED)}, + "dirvish":{"label":"dirvish","preview":"dirvish","faces":face_rows(DIRVISH_FACES,"dirvish-",DIRVISH_SEED)}, + "calibredb":{"label":"calibredb","preview":"calibredb","faces":face_rows(CALIBREDB_FACES,"calibredb-",CALIBREDB_SEED)}, + "erc":{"label":"erc","preview":"erc","faces":face_rows(ERC_FACES,"erc-",ERC_SEED)}, + "org-drill":{"label":"org-drill","preview":"orgdrill","faces":face_rows(ORGDRILL_FACES,"org-drill-",ORGDRILL_SEED)}, + "org-noter":{"label":"org-noter","preview":"orgnoter","faces":face_rows(ORGNOTER_FACES,"org-noter-",ORGNOTER_SEED)}, + "signel":{"label":"signel","preview":"signel","faces":face_rows(SIGNEL_FACES,"signel-",SIGNEL_SEED)}, + "pearl":{"label":"pearl","preview":"pearl","faces":face_rows(PEARL_FACES,"pearl-",PEARL_SEED)}, + "slack":{"label":"slack","preview":"slack","faces":face_rows(SLACK_FACES,"slack-",SLACK_SEED)}, + "telega":{"label":"telega","preview":"telega","faces":face_rows(TELEGA_FACES,"telega-",TELEGA_SEED)}, + "shr":{"label":"shr (HTML: nov/eww/mail)","preview":"shr","faces":face_rows(SHR_FACES,"shr-",SHR_SEED)}} +# 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. +if _seed: + apply_package_overrides(APPS, _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 DEFAULTS.label(value,'color-'+str(len(PALETTE))) + base=name + n=2 + used={p[1].lower() for p in PALETTE} + while name.lower() in used: + name=base+'-'+str(n); n+=1 + PALETTE.append([value,name,column_id(name)]) + +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(): + add_palette_color(_spec.get('fg')) + add_palette_color(_spec.get('bg')) + for _app in APPS.values(): + for _face,_label,_spec in _app['faces']: + add_palette_color(_spec.get('fg')) + add_palette_color(_spec.get('bg')) + +PALETTE=normalize_palette(PALETTE) +HTML=open(os.path.join(HERE,'theme-studio.template.html')).read() +# 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_ACTIONS_J",PALETTE_ACTIONS_BODY) + .replace("BROWSER_GATES_J",BROWSER_GATES_BODY) + .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("BOLD_J",json.dumps(BOLD)).replace("MAP_J",json.dumps(MAP)).replace("LOCKS_J",json.dumps(LOCKS)).replace("ITALIC_J",json.dumps({k:True for k in ITALIC}))) + +# 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 __name__=='__main__': + open(OUT,"w").write(HTML) + print("wrote",OUT) |
