aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-13 15:17:59 -0500
committerCraig Jennings <c@cjennings.net>2026-06-13 15:17:59 -0500
commitedeeb29c6bd7457eb4b43a9767373f94ee036814 (patch)
treeecb0732394d8f9938b2953786cf6de6af3570c99 /scripts/theme-studio
parentb76521ffff0ed53b05817878bf51e04db9f837c4 (diff)
downloaddotemacs-edeeb29c6bd7457eb4b43a9767373f94ee036814.tar.gz
dotemacs-edeeb29c6bd7457eb4b43a9767373f94ee036814.zip
Refactor theme studio face assembly
Diffstat (limited to 'scripts/theme-studio')
-rw-r--r--scripts/theme-studio/app-core.js12
-rw-r--r--scripts/theme-studio/app.js7
-rw-r--r--scripts/theme-studio/app_inventory.py76
-rw-r--r--scripts/theme-studio/face_specs.py37
-rw-r--r--scripts/theme-studio/generate.py73
-rw-r--r--scripts/theme-studio/test-app-core.mjs9
-rw-r--r--scripts/theme-studio/test_generate.py35
-rw-r--r--scripts/theme-studio/theme-studio.html17
8 files changed, 205 insertions, 61 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index ff87d53f..df3f0c35 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -14,14 +14,20 @@ import { oklch2hex, srgb2oklab, oklab2oklch, contrast } from './colormath.js';
// Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown.
function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;}
+function normalizePkgFace(d,source,palette){
+ d=d||{};
+ const resolve=(v)=>palette?nameToHex(v,palette):v;
+ return {fg:resolve(d.fg)??null,bg:resolve(d.bg)??null,bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit??null,height:d.height||1,box:d.box??null,source:source||d.source||'user'};
+}
+
// Seed the package-face map from the app inventory's per-face defaults.
-function buildPkgmap(apps,palette){const m={};for(const app in apps){m[app]={};for(const row of apps[app].faces){const face=row[0],d=row[2]||{};m[app][face]={fg:nameToHex(d.fg,palette),bg:nameToHex(d.bg,palette),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,box:d.box||null,source:'default'};}}return m;}
+function buildPkgmap(apps,palette){const m={};for(const app in apps){m[app]={};for(const row of apps[app].faces){m[app][row[0]]=normalizePkgFace(row[2],'default',palette);}}return m;}
// The package faces worth exporting (anything seeded or user-touched), trimmed.
function packagesForExport(map){const out={};for(const app in map){const faces={};for(const face in map[app]){const f=map[app][face];if(f.source==='default'||f.source==='user'||f.source==='cleared'){const o={fg:f.fg,bg:f.bg,bold:f.bold,italic:f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit,source:f.source};if(f.height&&f.height!==1)o.height=f.height;if(f.box)o.box=f.box;faces[face]=o;}}if(Object.keys(faces).length)out[app]=faces;}return out;}
// Merge an imported package block into a face map, filling missing fields.
-function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]={fg:f.fg??null,bg:f.bg??null,bold:!!f.bold,italic:!!f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit??null,height:f.height||1,box:f.box??null,source:f.source||'user'};}}}
+function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]=normalizePkgFace(f,f.source||'user');}}}
// Effective fg/bg for a package face, following its inherit chain. seen guards
// against an inherit cycle (returns null rather than recursing forever).
@@ -217,4 +223,4 @@ function paletteOptionList(cur,palette,ground){
return out;
}
-export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers };
+export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index e7e983f1..5a1c99f2 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -105,12 +105,12 @@ function clearUnlocked(){
buildTable();renderCode();notify('cleared unlocked elements to default',false);
}
function clearUnlockedUI(){
- clearUnlockedRows(UI_FACES,f=>'ui:'+f[0],f=>{UIMAP[f[0]]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false};});
+ clearUnlockedRows(UI_FACES,f=>'ui:'+f[0],f=>{UIMAP[f[0]]=uiFaceBlank();});
buildUITable();buildMockFrame();notify('cleared unlocked UI faces to default',false);
}
function clearUnlockedPkg(){
const app=curApp();
- clearUnlockedRows(APPS[app].faces,f=>'pkg:'+app+':'+f[0],f=>{PKGMAP[app][f[0]]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false,inherit:null,height:1,source:'cleared'};});
+ clearUnlockedRows(APPS[app].faces,f=>'pkg:'+app+':'+f[0],f=>{PKGMAP[app][f[0]]=normalizePkgFace({source:'cleared'},'cleared');});
pkgChanged();notify('cleared unlocked '+app+' faces to default',false);
}
function buildTable(){
@@ -564,7 +564,8 @@ function buildMockFrame(){
function uiSelect(face,attr){const cur=UIMAP[face][attr]||'';
return mkColorDropdown(ddList(cur),cur,h=>{UIMAP[face][attr]=h||null;paintUI(face);buildMockFrame();});}
const BASE_INHERITS=['fixed-pitch','variable-pitch','default','link','bold','italic','shadow'];
-function seedFace(d){return {fg:pname(d.fg),bg:pname(d.bg),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,box:d.box||null,source:'default'};}
+function uiFaceBlank(){return {fg:null,bg:null,bold:false,italic:false,underline:false,strike:false};}
+function seedFace(d){return normalizePkgFace({fg:pname(d.fg),bg:pname(d.bg),bold:d.bold,italic:d.italic,underline:d.underline,strike:d.strike,inherit:d.inherit,height:d.height,box:d.box},'default');}
function curApp(){const s=document.getElementById('appsel');return s&&s.value?s.value:Object.keys(APPS)[0];}
function pkgEffFg(app,face,seen){return effResolve(PKGMAP,app,face,'fg',seen);}
function pkgEffBg(app,face,seen){return effResolve(PKGMAP,app,face,'bg',seen);}
diff --git a/scripts/theme-studio/app_inventory.py b/scripts/theme-studio/app_inventory.py
new file mode 100644
index 00000000..0c55a5d4
--- /dev/null
+++ b/scripts/theme-studio/app_inventory.py
@@ -0,0 +1,76 @@
+"""Theme-studio package/app face inventory assembly helpers."""
+
+from __future__ import annotations
+
+import json
+import os
+from typing import Any
+
+
+BESPOKE_APPS = {
+ "magit",
+ "elfeed",
+ "org",
+ "org-mode",
+ "mu4e",
+ "ghostel",
+ "dashboard",
+ "lsp-mode",
+ "git-gutter",
+ "flycheck",
+ "dired",
+ "dirvish",
+ "calibredb",
+ "erc",
+ "org-drill",
+ "org-noter",
+ "signel",
+ "pearl",
+ "slack",
+ "telega",
+ "shr",
+}
+
+
+def face_label(face: str, prefix: str) -> str:
+ label = face[len(prefix) :] if face.startswith(prefix) else face
+ return label.replace("-face", "").replace("-", " ")
+
+
+def face_rows(names: list[str], prefix: str, seed: dict[str, dict[str, Any]]) -> list[list[Any]]:
+ return [[face, face_label(face, prefix), seed.get(face, {})] for face in names]
+
+
+def add_inventory_apps(apps: dict[str, Any], inventory_path: str) -> dict[str, Any]:
+ """Add generic editable apps for installed packages not covered by bespoke previews."""
+ if not os.path.exists(inventory_path):
+ return apps
+ inventory = json.load(open(inventory_path))
+ for pkg in sorted(inventory):
+ if pkg in BESPOKE_APPS or pkg in apps:
+ continue
+ apps[pkg] = {
+ "label": pkg,
+ "preview": "generic",
+ "faces": [[face, face_label(face, pkg + "-"), {}] for face in inventory[pkg]],
+ }
+ return apps
+
+
+def apply_default_face_seeds(apps: dict[str, Any], defaults: Any) -> None:
+ if not defaults.available:
+ return
+ for app in apps.values():
+ for row in app["faces"]:
+ row[2] = defaults.seed(row[0], False)
+
+
+def apply_package_overrides(apps: dict[str, Any], packages: dict[str, Any] | None) -> None:
+ if not packages:
+ return
+ for app, package_faces in packages.items():
+ if app not in apps:
+ continue
+ for row in apps[app]["faces"]:
+ if row[0] in package_faces:
+ row[2] = package_faces[row[0]]
diff --git a/scripts/theme-studio/face_specs.py b/scripts/theme-studio/face_specs.py
new file mode 100644
index 00000000..32de68e0
--- /dev/null
+++ b/scripts/theme-studio/face_specs.py
@@ -0,0 +1,37 @@
+"""Shared face-spec defaults for theme-studio generation."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+STYLE_DEFAULTS: dict[str, Any] = {
+ "fg": None,
+ "bg": None,
+ "bold": False,
+ "italic": False,
+ "underline": False,
+ "strike": False,
+}
+
+PACKAGE_DEFAULTS: dict[str, Any] = {
+ **STYLE_DEFAULTS,
+ "inherit": None,
+ "height": 1,
+ "box": None,
+}
+
+
+def face_spec(spec: dict[str, Any] | None = None, *, package: bool = False) -> dict[str, Any]:
+ out = dict(PACKAGE_DEFAULTS if package else STYLE_DEFAULTS)
+ if spec:
+ out.update(spec)
+ return out
+
+
+def ui_face_spec(spec: dict[str, Any] | None = None) -> dict[str, Any]:
+ return face_spec(spec, package=False)
+
+
+def package_face_spec(spec: dict[str, Any] | None = None) -> dict[str, Any]:
+ return face_spec(spec, package=True)
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index be526242..87264313 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -1,5 +1,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_specs import ui_face_spec
HERE=os.path.dirname(os.path.abspath(__file__))
def strip_exports(src):
@@ -87,9 +89,9 @@ 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={f[0]:{"fg":None,"bg":None,"bold":False,"italic":False,"underline":False,"strike":False} for f in UI_FACES}
+UIMAP={f[0]:ui_face_spec() 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}
+ 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
@@ -413,54 +415,35 @@ SHR_FACES=("shr-h1 shr-h2 shr-h3 shr-h4 shr-h5 shr-h6 shr-text shr-link shr-sele
SHR_SEED={
"shr-h1":{"fg":"gold","bold":True,"height":1.4},"shr-h2":{"fg":"blue","bold":True,"height":1.2},"shr-h3":{"fg":"blue","bold":True},"shr-h4":{"fg":"silver","bold":True},"shr-h5":{"fg":"steel","bold":True},"shr-h6":{"fg":"pewter","bold":True},
"shr-text":{"fg":"#cdced1"},"shr-link":{"fg":"blue","underline":True},"shr-selected-link":{"fg":"gold","bold":True,"underline":True},"shr-code":{"fg":"terracotta","bg":"bg-dim"},"shr-mark":{"fg":"#000000","bg":"gold"},"shr-strike-through":{"fg":"pewter","strike":True},"shr-sup":{"fg":"steel","height":0.8},"shr-abbreviation":{"fg":"steel","italic":True,"underline":True}}
-def _faces(names,prefix,seed):
- out=[]
- for f in names:
- lbl=(f[len(prefix):] if f.startswith(prefix) else f).replace("-face","").replace("-"," ")
- out.append([f,lbl,seed.get(f,{})])
- return out
-APPS={"org-mode":{"label":"org-mode","preview":"org","faces":_faces(ORG_FACES,"org-",ORG_SEED)},
- "magit":{"label":"magit","preview":"magit","faces":_faces(MAGIT_FACES,"magit-",MAGIT_SEED)},
- "elfeed":{"label":"elfeed","preview":"elfeed","faces":_faces(ELFEED_FACES,"elfeed-",ELFEED_SEED)},
- "mu4e":{"label":"mu4e","preview":"mu4e","faces":_faces(MU4E_FACES,"mu4e-",MU4E_SEED)},
- "ghostel":{"label":"ghostel","preview":"ghostel","faces":_faces(GHOSTEL_FACES,"ghostel-",GHOSTEL_SEED)},
- "dashboard":{"label":"dashboard","preview":"dashboard","faces":_faces(DASHBOARD_FACES,"dashboard-",DASHBOARD_SEED)},
- "lsp-mode":{"label":"lsp-mode","preview":"lsp","faces":_faces(LSP_FACES,"lsp-",LSP_SEED)},
- "git-gutter":{"label":"git-gutter","preview":"gitgutter","faces":_faces(GITGUTTER_FACES,"git-gutter:",GITGUTTER_SEED)},
- "flycheck":{"label":"flycheck","preview":"flycheck","faces":_faces(FLYCHECK_FACES,"flycheck-",FLYCHECK_SEED)},
- "dired":{"label":"dired","preview":"dired","faces":_faces(DIRED_FACES,"dired-",DIRED_SEED)},
- "dirvish":{"label":"dirvish","preview":"dirvish","faces":_faces(DIRVISH_FACES,"dirvish-",DIRVISH_SEED)},
- "calibredb":{"label":"calibredb","preview":"calibredb","faces":_faces(CALIBREDB_FACES,"calibredb-",CALIBREDB_SEED)},
- "erc":{"label":"erc","preview":"erc","faces":_faces(ERC_FACES,"erc-",ERC_SEED)},
- "org-drill":{"label":"org-drill","preview":"orgdrill","faces":_faces(ORGDRILL_FACES,"org-drill-",ORGDRILL_SEED)},
- "org-noter":{"label":"org-noter","preview":"orgnoter","faces":_faces(ORGNOTER_FACES,"org-noter-",ORGNOTER_SEED)},
- "signel":{"label":"signel","preview":"signel","faces":_faces(SIGNEL_FACES,"signel-",SIGNEL_SEED)},
- "pearl":{"label":"pearl","preview":"pearl","faces":_faces(PEARL_FACES,"pearl-",PEARL_SEED)},
- "slack":{"label":"slack","preview":"slack","faces":_faces(SLACK_FACES,"slack-",SLACK_SEED)},
- "telega":{"label":"telega","preview":"telega","faces":_faces(TELEGA_FACES,"telega-",TELEGA_SEED)},
- "shr":{"label":"shr (HTML: nov/eww/mail)","preview":"shr","faces":_faces(SHR_FACES,"shr-",SHR_SEED)}}
+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")
-if os.path.exists(_inv_path):
- _INV=json.load(open(_inv_path))
- _BESPOKE={"magit","elfeed","org","org-mode","mu4e","ghostel","dashboard","lsp-mode","git-gutter","flycheck","dired","dirvish","calibredb","erc","org-drill","org-noter","signel","pearl","slack","telega","shr"}
- for _pkg in sorted(_INV):
- if _pkg in _BESPOKE or _pkg in APPS: continue
- 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 DEFAULTS.available:
- for _app in APPS.values():
- for _row in _app["faces"]:
- _row[2]=DEFAULTS.seed(_row[0],False)
+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 and _d.get('packages'):
- for _app,_pkfaces in _d['packages'].items():
- if _app in APPS:
- for _row in APPS[_app]['faces']:
- if _row[0] in _pkfaces: _row[2]=_pkfaces[_row[0]]
+if _seed:
+ apply_package_overrides(APPS, _d.get('packages'))
def add_palette_color(value, label=None):
if not value: return
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index 244dd6e3..0cdc37b5 100644
--- a/scripts/theme-studio/test-app-core.mjs
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
- nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify,
+ nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify,
} from './app-core.js';
const here = fileURLToPath(new URL('.', import.meta.url));
@@ -84,6 +84,13 @@ test('buildPkgmap: Normal — seeds faces, resolving names and applying defaults
assert.equal(m['org-mode']['org-done'].fg, null);
});
+test('normalizePkgFace: Normal — fills every package face field', () => {
+ assert.deepEqual(normalizePkgFace({ fg: 'blue', bold: true, inherit: 'base' }, 'default', PAL), {
+ fg: '#67809c', bg: null, bold: true, italic: false, underline: false,
+ strike: false, inherit: 'base', height: 1, box: null, source: 'default',
+ });
+});
+
test('buildPkgmap: Boundary — a face with no default dict still seeds blank', () => {
const m = buildPkgmap({ a: { faces: [['f', 'f']] } }, PAL);
assert.deepEqual(m.a.f, {
diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py
index 0e46a7bb..d1e6ee7a 100644
--- a/scripts/theme-studio/test_generate.py
+++ b/scripts/theme-studio/test_generate.py
@@ -13,7 +13,9 @@ import unittest
from collections import Counter, defaultdict
import generate # importable without side effects: the file write is __main__-guarded
+from app_inventory import face_rows
from default_faces import DefaultFaces
+from face_specs import package_face_spec, ui_face_spec
class StripExports(unittest.TestCase):
@@ -117,7 +119,7 @@ class AssembledPage(unittest.TestCase):
class FacesHelper(unittest.TestCase):
def test_strips_prefix_and_derives_label_and_merges_seed(self):
# Normal: the prefix comes off the label, and the per-face seed is attached.
- rows = generate._faces(["org-todo", "org-done"], "org-", {"org-todo": {"fg": "gold"}})
+ rows = face_rows(["org-todo", "org-done"], "org-", {"org-todo": {"fg": "gold"}})
self.assertEqual(rows, [
["org-todo", "todo", {"fg": "gold"}],
["org-done", "done", {}],
@@ -125,18 +127,43 @@ class FacesHelper(unittest.TestCase):
def test_label_drops_face_suffix_and_spaces_remaining_dashes(self):
# Boundary: "-face" is removed and the rest of the dashes become spaces.
- rows = generate._faces(["lsp-rename-placeholder-face"], "lsp-", {})
+ rows = face_rows(["lsp-rename-placeholder-face"], "lsp-", {})
self.assertEqual(rows[0][1], "rename placeholder")
def test_name_without_the_prefix_is_left_intact(self):
# Boundary: a name that doesn't start with the prefix keeps its full text
# (only "-face" removal and dash-spacing apply).
- rows = generate._faces(["shr-text"], "org-", {})
+ rows = face_rows(["shr-text"], "org-", {})
self.assertEqual(rows[0], ["shr-text", "shr text", {}])
def test_empty_names_gives_empty_list(self):
# Error/Boundary: nothing in, nothing out.
- self.assertEqual(generate._faces([], "org-", {"org-todo": {"fg": "gold"}}), [])
+ self.assertEqual(face_rows([], "org-", {"org-todo": {"fg": "gold"}}), [])
+
+
+class FaceSpecDefaults(unittest.TestCase):
+ def test_ui_face_spec_fills_style_fields(self):
+ self.assertEqual(ui_face_spec({"bg": "#ffffff", "bold": True}), {
+ "fg": None,
+ "bg": "#ffffff",
+ "bold": True,
+ "italic": False,
+ "underline": False,
+ "strike": False,
+ })
+
+ def test_package_face_spec_fills_structure_fields(self):
+ self.assertEqual(package_face_spec({"inherit": "base", "height": 1.2}), {
+ "fg": None,
+ "bg": None,
+ "bold": False,
+ "italic": False,
+ "underline": False,
+ "strike": False,
+ "inherit": "base",
+ "height": 1.2,
+ "box": None,
+ })
class DefaultFaceAdapter(unittest.TestCase):
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 7a3aecf9..aa84f75d 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -425,14 +425,20 @@ function reliefColors(bgHex) {
// Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown.
function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;}
+function normalizePkgFace(d,source,palette){
+ d=d||{};
+ const resolve=(v)=>palette?nameToHex(v,palette):v;
+ return {fg:resolve(d.fg)??null,bg:resolve(d.bg)??null,bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit??null,height:d.height||1,box:d.box??null,source:source||d.source||'user'};
+}
+
// Seed the package-face map from the app inventory's per-face defaults.
-function buildPkgmap(apps,palette){const m={};for(const app in apps){m[app]={};for(const row of apps[app].faces){const face=row[0],d=row[2]||{};m[app][face]={fg:nameToHex(d.fg,palette),bg:nameToHex(d.bg,palette),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,box:d.box||null,source:'default'};}}return m;}
+function buildPkgmap(apps,palette){const m={};for(const app in apps){m[app]={};for(const row of apps[app].faces){m[app][row[0]]=normalizePkgFace(row[2],'default',palette);}}return m;}
// The package faces worth exporting (anything seeded or user-touched), trimmed.
function packagesForExport(map){const out={};for(const app in map){const faces={};for(const face in map[app]){const f=map[app][face];if(f.source==='default'||f.source==='user'||f.source==='cleared'){const o={fg:f.fg,bg:f.bg,bold:f.bold,italic:f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit,source:f.source};if(f.height&&f.height!==1)o.height=f.height;if(f.box)o.box=f.box;faces[face]=o;}}if(Object.keys(faces).length)out[app]=faces;}return out;}
// Merge an imported package block into a face map, filling missing fields.
-function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]={fg:f.fg??null,bg:f.bg??null,bold:!!f.bold,italic:!!f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit??null,height:f.height||1,box:f.box??null,source:f.source||'user'};}}}
+function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]=normalizePkgFace(f,f.source||'user');}}}
// Effective fg/bg for a package face, following its inherit chain. seen guards
// against an inherit cycle (returns null rather than recursing forever).
@@ -732,12 +738,12 @@ function clearUnlocked(){
buildTable();renderCode();notify('cleared unlocked elements to default',false);
}
function clearUnlockedUI(){
- clearUnlockedRows(UI_FACES,f=>'ui:'+f[0],f=>{UIMAP[f[0]]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false};});
+ clearUnlockedRows(UI_FACES,f=>'ui:'+f[0],f=>{UIMAP[f[0]]=uiFaceBlank();});
buildUITable();buildMockFrame();notify('cleared unlocked UI faces to default',false);
}
function clearUnlockedPkg(){
const app=curApp();
- clearUnlockedRows(APPS[app].faces,f=>'pkg:'+app+':'+f[0],f=>{PKGMAP[app][f[0]]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false,inherit:null,height:1,source:'cleared'};});
+ clearUnlockedRows(APPS[app].faces,f=>'pkg:'+app+':'+f[0],f=>{PKGMAP[app][f[0]]=normalizePkgFace({source:'cleared'},'cleared');});
pkgChanged();notify('cleared unlocked '+app+' faces to default',false);
}
function buildTable(){
@@ -1191,7 +1197,8 @@ function buildMockFrame(){
function uiSelect(face,attr){const cur=UIMAP[face][attr]||'';
return mkColorDropdown(ddList(cur),cur,h=>{UIMAP[face][attr]=h||null;paintUI(face);buildMockFrame();});}
const BASE_INHERITS=['fixed-pitch','variable-pitch','default','link','bold','italic','shadow'];
-function seedFace(d){return {fg:pname(d.fg),bg:pname(d.bg),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,box:d.box||null,source:'default'};}
+function uiFaceBlank(){return {fg:null,bg:null,bold:false,italic:false,underline:false,strike:false};}
+function seedFace(d){return normalizePkgFace({fg:pname(d.fg),bg:pname(d.bg),bold:d.bold,italic:d.italic,underline:d.underline,strike:d.strike,inherit:d.inherit,height:d.height,box:d.box},'default');}
function curApp(){const s=document.getElementById('appsel');return s&&s.value?s.value:Object.keys(APPS)[0];}
function pkgEffFg(app,face,seen){return effResolve(PKGMAP,app,face,'fg',seen);}
function pkgEffBg(app,face,seen){return effResolve(PKGMAP,app,face,'bg',seen);}