From edeeb29c6bd7457eb4b43a9767373f94ee036814 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 13 Jun 2026 15:17:59 -0500 Subject: Refactor theme studio face assembly --- scripts/theme-studio/app-core.js | 12 ++++-- scripts/theme-studio/app.js | 7 ++-- scripts/theme-studio/app_inventory.py | 76 ++++++++++++++++++++++++++++++++++ scripts/theme-studio/face_specs.py | 37 +++++++++++++++++ scripts/theme-studio/generate.py | 73 +++++++++++++------------------- scripts/theme-studio/test-app-core.mjs | 9 +++- scripts/theme-studio/test_generate.py | 35 ++++++++++++++-- scripts/theme-studio/theme-studio.html | 17 +++++--- 8 files changed, 205 insertions(+), 61 deletions(-) create mode 100644 scripts/theme-studio/app_inventory.py create mode 100644 scripts/theme-studio/face_specs.py (limited to 'scripts/theme-studio') 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= 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);} -- cgit v1.2.3