From fa5b28ea69f3bff0941f8a097a9746b7a67fa900 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 24 Jun 2026 14:44:28 -0400 Subject: feat(theme-studio): nerd-icons gallery as a hue-ordered icon grid The nerd-icons pane is now a grid: one row per color face, the rows ordered by hue so families cluster, distinct icons (deduped within a color) drawn in their color with the icon's nerd-font name beneath. A "preview:" dropdown above the grid picks the glyph size in points, with Left/Right arrows to step it. Single-pane apps show it disabled, naming the preview. This replaces the v1 legend in the pane, whose data is still captured for round-trip. build-nerd-icons-legend.el is now a library. A cj/nerd-icons-write-legend entry point requires nerd-icons only at write time, so the capture logic loads and unit-tests without it. It dedupes icons by name within a face, computes each face's native hue, and orders the groups by hue. Writing the test surfaced a latent bug: face-hsl used (cadr (assoc t spec)), which grabs the first keyword instead of the plist. It only worked because the real faces fall through to the face-foreground branch. I fixed it to a correct t-clause parse. Coverage: 7 ERT capture tests (dedupe, hue order, lightness tiebreak, name sort, skip rules), 4 Python validator edges, and browser gates for the grid and the size dropdown. Locate stays color-level: clicking a color flashes its icons, and clicking an icon flashes its color row. Icons aren't individually editable, so there's nothing per-icon to select. --- scripts/theme-studio/app.js | 61 +- scripts/theme-studio/app_inventory.py | 19 +- scripts/theme-studio/browser-gates.js | 71 +- scripts/theme-studio/build-nerd-icons-legend.el | 152 +- scripts/theme-studio/generate.py | 54 +- scripts/theme-studio/nerd-icons-legend.json | 1650 ++++++++++++++++++-- scripts/theme-studio/previews.js | 39 +- scripts/theme-studio/run-tests.sh | 3 +- .../theme-studio/test-nerd-icons-legend-dump.el | 111 ++ scripts/theme-studio/test_generate.py | 109 ++ scripts/theme-studio/theme-studio.html | 175 ++- scripts/theme-studio/theme-studio.template.html | 2 +- 12 files changed, 2260 insertions(+), 186 deletions(-) create mode 100644 scripts/theme-studio/test-nerd-icons-legend-dump.el (limited to 'scripts') diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 0faf9923f..338d84743 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -768,20 +768,65 @@ const PACKAGE_PREVIEWS={ pearl:renderPearlPreview,slack:renderSlackPreview,telega:renderTelegaPreview,shr:renderShrPreview, nerdicons:renderNerdIconsPreview }; +// Preview panes for an app. Most apps have a single pane (the dropdown shows its +// name and is disabled). nerd-icons is the one multi-pane app: one pane per font +// size, so the designer can view the icon grid at different sizes — pt because +// Emacs sizes fonts in :height (1/10 pt), so a pane maps to a real buffer size. +const NERD_ICON_SIZES_PT=[10,12,14,16,20,24]; +const NERD_ICON_DEFAULT_PT=14; +function previewPanes(app){ + // Multi-pane only when nerd-icons actually has a gallery to size. If the gallery + // capture failed (nerd-icons absent, alists changed, empty), the grid renderer + // falls back to the generic preview, so offering size panes would be a lie — the + // dropdown collapses to one pane and is disabled. + if(app==='nerd-icons'&&APPS[app]&&Array.isArray(APPS[app].gallery)&&APPS[app].gallery.length) + return NERD_ICON_SIZES_PT.map(pt=>({label:'nerd-icons — '+pt+' pt',size:pt})); + return [{label:PACKAGE_PREVIEWS[APPS[app].preview]?APPS[app].label:'generic (face names in their own colors)'}]; +} +function defaultPaneIdx(app){ + if(app==='nerd-icons')return Math.max(0,NERD_ICON_SIZES_PT.indexOf(NERD_ICON_DEFAULT_PT)); + return 0; +} +// Per-app selected pane index, so a chosen size survives edits and revisits. +const PREV_PANE={}; function buildPkgPreview(){ const app=curApp(),p=document.getElementById('pkgpreview');if(!p)return; rebuildLocateRegistry(); - const renderer=PACKAGE_PREVIEWS[APPS[app].preview]; - p.innerHTML=renderer?renderer():genericPreview(app); + const panes=previewPanes(app); + let idx=PREV_PANE[app]; + if(idx==null||idx>=panes.length){idx=defaultPaneIdx(app);PREV_PANE[app]=idx;} + const pane=panes[idx],renderer=PACKAGE_PREVIEWS[APPS[app].preview]; + // A pane carrying a size is a nerd-icons size variant; render the grid at it. + p.innerHTML=pane.size!=null?renderNerdIconsPreview(pane.size):(renderer?renderer():genericPreview(app)); p.style.background=MAP['bg']; p.onclick=(e)=>locateClick(e,app); - const lbl=document.getElementById('pkgprevlabel'),baseLabel=renderer?(APPS[app].label+' preview'):'preview (generic — face names in their own colors)'; - if(lbl)lbl.textContent=baseLabel; + // The pane dropdown: disabled when there's only one pane (it just names the + // preview), enabled when there are several (it selects which one shows). + const sel=document.getElementById('pkgprevsel'); + if(sel){ + sel.innerHTML=panes.map((pn,i)=>``).join(''); + sel.value=String(idx); + sel.disabled=panes.length<2; + sel.onchange=()=>{PREV_PANE[app]=+sel.value;buildPkgPreview();}; + // Left/Right arrows step through the panes when the dropdown is focused + // (Up/Down already do, natively); clamped at the ends. Re-render and refocus, + // since rebuilding the options would otherwise drop keyboard focus. + sel.onkeydown=(e)=>{ + if(e.key!=='ArrowLeft'&&e.key!=='ArrowRight')return; + e.preventDefault(); + const cur=+sel.value,nxt=e.key==='ArrowRight'?Math.min(cur+1,panes.length-1):Math.max(cur-1,0); + if(nxt===cur)return; + PREV_PANE[app]=nxt;buildPkgPreview(); + const s=document.getElementById('pkgprevsel');if(s)s.focus(); + }; + } // Immediate-wayfinding info line: hovering an element shows "section > face — - // value" in the label area (the element's title is the deterministic fallback); - // leaving the preview restores the base label. - p.onmouseover=(e)=>{const u=e.target.closest('[data-owner-app]');if(!u||!lbl)return;lbl.textContent=locateInfoLine(locateFaceMeta(u.dataset.ownerApp,u.dataset.face,LOCATE_REG));}; - p.onmouseleave=()=>{if(lbl)lbl.textContent=baseLabel;}; + // value" next to the dropdown (the element's title is the deterministic + // fallback); leaving the preview clears it. + const info=document.getElementById('pkgprevinfo'); + if(info)info.textContent=''; + p.onmouseover=(e)=>{const u=e.target.closest('[data-owner-app]');if(!u||!info)return;info.textContent=locateInfoLine(locateFaceMeta(u.dataset.ownerApp,u.dataset.face,LOCATE_REG));}; + p.onmouseleave=()=>{if(info)info.textContent='';}; } function resetApp(){const app=curApp();for(const [face,,d] of APPS[app].faces)if(!LOCKED.has('pkg:'+app+':'+face))PKGMAP[app][face]=seedFace(d);pkgChanged();notify('reset editable '+app+' faces to package defaults',false);} function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} diff --git a/scripts/theme-studio/app_inventory.py b/scripts/theme-studio/app_inventory.py index 09e2ed0a0..ade71a0ef 100644 --- a/scripts/theme-studio/app_inventory.py +++ b/scripts/theme-studio/app_inventory.py @@ -50,15 +50,19 @@ def add_inventory_apps(apps: dict[str, Any], inventory_path: str) -> dict[str, A return apps -def add_nerd_icons_app(apps: dict[str, Any], inventory_path: str, legend: Any) -> dict[str, Any]: +def add_nerd_icons_app(apps: dict[str, Any], inventory_path: str, legend: Any, + gallery: Any = None) -> dict[str, Any]: """Register nerd-icons as a bespoke legend app from its inventory faces. The 34 nerd-icons color faces stay editable rows; LEGEND (the validated rows from generate.load_nerd_icons_legend) rides the app so the bespoke previews.js - renderer can draw each filetype glyph in its mapped face color. A no-op when - LEGEND is falsy or the inventory lacks nerd-icons -- the caller guards on a - valid legend, and add_inventory_apps then creates the generic fallback app. - Must run before add_inventory_apps so the generic path skips nerd-icons. + renderer can draw each filetype glyph in its mapped face color. GALLERY (the + full colored catalog grouped by face, from generate.load_nerd_icons_gallery) + rides alongside when present so the same renderer can draw the gallery section; + a falsy GALLERY simply omits it (legend-only). A no-op when LEGEND is falsy or + the inventory lacks nerd-icons -- the caller guards on a valid legend, and + add_inventory_apps then creates the generic fallback app. Must run before + add_inventory_apps so the generic path skips nerd-icons. """ if not legend or not os.path.exists(inventory_path): return apps @@ -66,12 +70,15 @@ def add_nerd_icons_app(apps: dict[str, Any], inventory_path: str, legend: Any) - faces = json.load(src).get("nerd-icons") if not faces: return apps - apps["nerd-icons"] = { + app = { "label": "nerd-icons", "preview": "nerdicons", "faces": [[face, face_label(face, "nerd-icons-"), {}] for face in faces], "legend": legend, } + if gallery: + app["gallery"] = gallery + apps["nerd-icons"] = app return apps diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js index 0f2f8e5a7..1157b0712 100644 --- a/scripts/theme-studio/browser-gates.js +++ b/scripts/theme-studio/browser-gates.js @@ -821,19 +821,32 @@ if(location.hash==='#nerdiconstest')gate('nerdiconstest',A=>{ A(Array.isArray(legend)&&legend.length>=10,'legend has the curated rows ('+legend.length+')'); const dir=legend.find(r=>r.key==='dir'); A(dir&&dir.face==='nerd-icons-yellow','dir row models nerd-icons-yellow'); + // Gallery: the full colored catalog as a grid — one row per color face, rows + // ordered by hue so families cluster, each color's distinct icons deduped. + const gallery=(APPS['nerd-icons']&&APPS['nerd-icons'].gallery)||[]; + A(Array.isArray(gallery)&&gallery.length>=30,'gallery has the color groups ('+gallery.length+')'); + const hues=gallery.map(g=>g.hue); + A(hues.every((hu,i)=>i===0||hues[i-1]<=hu),'gallery rows ordered by hue (families cluster)'); + A(gallery.every(g=>typeof g.face==='string'&&g.face.indexOf('nerd-icons-')===0&&typeof g.hue==='number'&&Array.isArray(g.glyphs)&&g.glyphs.length>0),'every gallery group is a real nerd-icons face with a hue and glyphs'); + A(gallery.every(g=>g.glyphs.every(e=>e.glyph&&e.name)),'every gallery glyph carries glyph and icon name'); + A(gallery.every(g=>new Set(g.glyphs.map(e=>e.name)).size===g.glyphs.length),'icons are deduplicated within each color row'); if(PACKAGE_PREVIEWS['nerdicons']&&APPS['nerd-icons']){ + // assertPreviewFaces over the grid — every data-face, across the ~314 deduped + // glyph cells and the per-row swatches, is a real nerd-icons face with a valid owner. assertPreviewFaces(A, renderNerdIconsPreview(), APPS['nerd-icons'].faces, 10, 'nerd-icons', ['nerd-icons-purple','nerd-icons-yellow','nerd-icons-blue','nerd-icons-dblue']); - // Recoloring a face repaints every legend row mapped to it (os reads the live registry). + // Recoloring a face repaints every element in its row (the swatch + each glyph + // cell), since os reads the live registry. withSavedState(['PKGMAP'],()=>{ - const target='nerd-icons-purple',mapped=legend.filter(r=>r.face===target); - A(mapped.length>=1,'at least one row maps to '+target); + const target='nerd-icons-purple',gGroup=gallery.find(g=>g.face===target); + const expected=gGroup?1+gGroup.glyphs.length:0; + A(!!gGroup,'gallery has a '+target+' row'); PKGMAP['nerd-icons']=PKGMAP['nerd-icons']||{}; PKGMAP['nerd-icons'][target]={fg:'#abcdef',bg:null,weight:null,slant:null,inherit:null,height:1,source:'user'}; const box=document.createElement('div');box.innerHTML=renderNerdIconsPreview(); const els=[...box.querySelectorAll('[data-face="'+target+'"]')]; - A(els.length===mapped.length,'every '+target+' row rendered ('+els.length+'/'+mapped.length+')'); - A(els.length>0&&els.every(e=>/#abcdef/i.test(e.getAttribute('style')||'')),'recolor repaints the mapped rows'); + A(els.length===expected,'every '+target+' element rendered, swatch+glyphs ('+els.length+'/'+expected+')'); + A(els.length>0&&els.every(e=>/#abcdef/i.test(e.getAttribute('style')||'')),'recolor repaints every element in the row'); }); // Export/import round-trip over an assigned nerd-icons color; the separate // nerd-icons-completion app (dir-face) is untouched by the nerd-icons pane. @@ -846,6 +859,44 @@ if(location.hash==='#nerdiconstest')gate('nerdiconstest',A=>{ A(!(exp['nerd-icons']&&('nerd-icons-completion-dir-face' in exp['nerd-icons'])),'dir-face stays out of the nerd-icons app'); } }); +// Preview-pane dropdown gate (open with #previewpanetest): the preview label is a +// "preview:" dropdown. A single-pane app shows its name disabled; nerd-icons is +// multi-pane (one pane per font size in pt), enabled, and selecting a size renders +// the grid at it. Locate is unaffected — the flash targets whatever pane is rendered. +if(location.hash==='#previewpanetest')gate('previewpanetest',A=>{ + const np=previewPanes('nerd-icons'); + A(np.length===NERD_ICON_SIZES_PT.length&&np.length>1,'nerd-icons is multi-pane, one per size ('+np.length+')'); + A(np.every(p=>typeof p.size==='number'&&/ pt$/.test(p.label)),'each nerd-icons pane carries a pt size and a label'); + A(NERD_ICON_SIZES_PT[defaultPaneIdx('nerd-icons')]===NERD_ICON_DEFAULT_PT,'nerd-icons defaults to '+NERD_ICON_DEFAULT_PT+' pt'); + const single=Object.keys(APPS).find(k=>k!=='nerd-icons'); + A(previewPanes(single).length===1,'a non-nerd-icons app has a single pane ('+single+')'); + // size drives the rendered glyph font-size; no arg defaults to 14 pt + const small=renderNerdIconsPreview(10),big=renderNerdIconsPreview(24); + A(/font-size:10pt/.test(small)&&!/font-size:24pt/.test(small),'10 pt pane renders glyphs at 10pt'); + A(/font-size:24pt/.test(big)&&!/font-size:10pt/.test(big),'24 pt pane renders glyphs at 24pt'); + A(/font-size:14pt/.test(renderNerdIconsPreview()),'default (no-arg) pane renders glyphs at 14 pt'); + // gallery-absent fallback: the dropdown must not promise sizes it can't render — + // with no gallery, one pane only and the grid falls back to the generic preview. + const savedG=APPS['nerd-icons'].gallery;delete APPS['nerd-icons'].gallery; + A(previewPanes('nerd-icons').length===1,'no gallery -> single pane (dropdown disabled)'); + A(!/ni-gallery/.test(renderNerdIconsPreview()),'no gallery -> grid falls back to the generic preview'); + APPS['nerd-icons'].gallery=savedG; + // DOM wiring: dropdown enabled+populated on nerd-icons, disabled on a single-pane app + const vs=document.getElementById('viewsel'),saved=vs&&vs.value; + if(vs){ + vs.value='nerd-icons'; + if(curApp()==='nerd-icons'){ + PREV_PANE['nerd-icons']=99; // a stale, out-of-range selection + buildPkgPreview(); + const sel=document.getElementById('pkgprevsel'); + A(+sel.value===defaultPaneIdx('nerd-icons'),'a stale pane index resets to the default'); + A(!sel.disabled&&sel.options.length===NERD_ICON_SIZES_PT.length,'nerd-icons: dropdown enabled with one option per size'); + vs.value=single;buildPkgPreview(); + A(sel.disabled&&sel.options.length===1,'single-pane app: dropdown disabled with one option'); + } + vs.value=saved;buildPkgPreview(); + } +}); // picker-distinct gate (open with #pickertest): the color picker panel must stand // out from the page background. It carries a highlighted gold accent border, and its // background is meaningfully lighter than the body so the two are easy to tell apart. @@ -1244,17 +1295,17 @@ if(location.hash==='#locatehovertest')gate('locatehovertest',A=>withSavedState([ rebuildLocateRegistry(); const cb=document.createElement('div');cb.innerHTML=os(app,face,'x'); A(/cleared, rendering as default/.test(cb.querySelector('[data-face]').getAttribute('title')),'cleared face title carries the cleared-rendering note'); - // info line on hover + // info line on hover — now a dedicated span next to the pane dropdown, cleared on leave PKGMAP[app][face]={fg:'#abcdef',bg:null,inherit:null,source:'user'}; buildPkgPreview(); - const p=document.getElementById('pkgpreview'),lbl=document.getElementById('pkgprevlabel'),base=lbl.textContent; + const p=document.getElementById('pkgpreview'),info=document.getElementById('pkgprevinfo'); rebuildLocateRegistry(); p.innerHTML=os(app,face,'hover me'); p.querySelector('[data-owner-app]').dispatchEvent(new MouseEvent('mouseover',{bubbles:true})); - A(lbl.textContent===locateInfoLine(locateFaceMeta(app,face,LOCATE_REG)),'hover updates the info line to section > face — value: '+lbl.textContent); - A(/ > .* — /.test(lbl.textContent),'info line uses the section > face — value shape'); + A(info.textContent===locateInfoLine(locateFaceMeta(app,face,LOCATE_REG)),'hover updates the info line to section > face — value: '+info.textContent); + A(/ > .* — /.test(info.textContent),'info line uses the section > face — value shape'); p.dispatchEvent(new MouseEvent('mouseleave')); - A(lbl.textContent===base,'leaving the preview restores the base label: '+lbl.textContent); + A(info.textContent==='','leaving the preview clears the info line'); })); // Click + cursor gate (open with #locateclicktest): an on-pane element carries the // locate-onpane class (pointer cursor) and clicking flashes its assignment row via diff --git a/scripts/theme-studio/build-nerd-icons-legend.el b/scripts/theme-studio/build-nerd-icons-legend.el index 6381294f5..fce63f161 100644 --- a/scripts/theme-studio/build-nerd-icons-legend.el +++ b/scripts/theme-studio/build-nerd-icons-legend.el @@ -1,16 +1,40 @@ -;;; build-nerd-icons-legend.el --- emit nerd-icons filetype legend for theme-studio -*- lexical-binding: t -*- +;;; build-nerd-icons-legend.el --- emit nerd-icons legend + gallery for theme-studio -*- lexical-binding: t -*- ;;; Commentary: -;; Loaded into a running Emacs (emacsclient -e '(load ".../build-nerd-icons-legend.el")') -;; to write nerd-icons-legend.json next to itself: the curated v1 filetype legend -;; for theme-studio's bespoke nerd-icons preview. Each row resolves its glyph and -;; owner color face from the live nerd-icons alists at capture time, so the legend -;; tracks the installed nerd-icons version. A curated key absent from the alist -;; is skipped and logged. generate.py embeds the JSON; see -;; docs/specs/theme-studio-nerd-icons-colors-spec.org. +;; A library of capture functions plus one entry point, cj/nerd-icons-write-legend, +;; that writes nerd-icons-legend.json next to this file. Invoke it from a running +;; Emacs (where nerd-icons is loaded): +;; +;; emacsclient -e '(progn (load ".../build-nerd-icons-legend.el") (cj/nerd-icons-write-legend))' +;; +;; The JSON is an object with two keys: +;; "legend" -- the curated v1 filetype legend (a representative row set drawn +;; from a diverse subset of the nerd-icons color faces). +;; "gallery" -- the full colored catalog (vNext): every distinct face-bearing +;; nerd-icons icon, grouped by owner color face, one group per face, +;; the groups ordered by hue so color families cluster. +;; Each legend row and gallery glyph resolves its glyph + owner face from the live +;; nerd-icons alists at capture time, so the artifact tracks the installed +;; nerd-icons version. A curated legend key absent from the alist is skipped and +;; logged; a gallery entry whose glyph or face won't resolve is skipped. +;; generate.py embeds the JSON; see docs/specs/theme-studio-nerd-icons-colors-spec.org. +;; +;; nerd-icons is required only at write time (inside cj/nerd-icons-write-legend), +;; not at load, so the pure capture functions load and unit-test without it (the +;; alist vars are declared special below and injected by the test). ;;; Code: (require 'json) -(require 'nerd-icons) +(require 'color) + +;; Declared, not required: nerd-icons supplies these at write time; the declarations +;; keep the byte-compiler quiet and let tests bind synthetic values without nerd-icons. +(defvar nerd-icons-extension-icon-alist) +(defvar nerd-icons-regexp-icon-alist) +(defvar nerd-icons-mode-icon-alist) +(defvar nerd-icons-completion-category-icons) +(declare-function nerd-icons-icon-for-dir "nerd-icons") + +;; ---- v1 legend (curated representative rows) ------------------------------ ;; Curated v1 rows: (KEY LABEL CATEGORY LOOKUP). CATEGORY selects the source ;; alist and its face shape; LOOKUP is the alist key (nil for the dir row, which @@ -41,7 +65,7 @@ set of the nerd-icons color faces rather than all 34.") (string-trim (substring-no-properties s)))))) (defun cj/--nerd-icons-legend-make (key label category glyph face) - "Build the JSON alist for one legend row, or nil (logged) when GLYPH/FACE missing." + "Build the JSON alist for one legend row, or nil (logged) if GLYPH/FACE absent." (if (and glyph face) (list (cons "key" key) (cons "label" label) @@ -82,15 +106,105 @@ set of the nerd-icons color faces rather than all 34.") (and (stringp s) (string-trim (substring-no-properties s)))) 'nerd-icons-yellow)))) -(let ((rows (delq nil (mapcar (lambda (r) (apply #'cj/--nerd-icons-legend-row r)) - cj/--nerd-icons-legend-spec)))) - (with-temp-file (expand-file-name - "nerd-icons-legend.json" - (file-name-directory (or load-file-name buffer-file-name - "~/.emacs.d/scripts/theme-studio/"))) - (let ((json-encoding-pretty-print t)) - (insert (json-encode (apply #'vector rows)) "\n"))) - (message "nerd-icons-legend: wrote %d rows" (length rows))) +(defun cj/--nerd-icons-legend-rows () + "Resolve the curated v1 legend rows as a list of JSON alists." + (delq nil (mapcar (lambda (r) (apply #'cj/--nerd-icons-legend-row r)) + cj/--nerd-icons-legend-spec))) + +;; ---- gallery (full colored catalog, a grid of distinct icons by color) ----- + +(defconst cj/--nerd-icons-gallery-alists + '(nerd-icons-extension-icon-alist + nerd-icons-regexp-icon-alist + nerd-icons-mode-icon-alist) + "Source alists for the gallery. Entries are shaped (KEY FN NAME :face FACE ...); +NAME is the nerd-font icon name (e.g. \"nf-dev-terminal\"). The dir alist carries +no :face (directory icons are colored by advice, not a per-entry face) and is +intentionally absent.") + +(defun cj/--nerd-icons-spec-foreground (spec) + "Return the :foreground of the default (t) display clause in SPEC, or nil. +The clause is (t . PLIST), so the foreground is plist-get of its cdr. A +display-conditional spec (no t clause, as the real nerd-icons faces use) returns +nil here and falls back to the live, frame-resolved face foreground." + (plist-get (cdr (assoc t spec)) :foreground)) + +(defun cj/--nerd-icons-face-hsl (face) + "Return (HUE SAT LIGHT) for FACE's foreground: hue 0-360, sat and light 0-100. +Use the t-clause defface color when there is one (deterministic), else the live +frame-resolved foreground. nil if no color resolves." + (let* ((fg (or (cj/--nerd-icons-spec-foreground (face-default-spec face)) + (face-foreground face nil 'default))) + (rgb (and (stringp fg) (ignore-errors (color-name-to-rgb fg)))) + (hsl (and rgb (apply #'color-rgb-to-hsl rgb)))) + (when hsl + (list (round (* 360 (nth 0 hsl))) + (round (* 100 (nth 1 hsl))) + (round (* 100 (nth 2 hsl))))))) + +(defun cj/--nerd-icons-gallery-groups () + "Build the gallery grid: a list of JSON group alists, one per owner color face, +ordered by hue (ascending, ties by descending lightness) so families cluster. +Each group is ((\"face\" . NAME) (\"hue\" . DEG) (\"glyphs\" . VECTOR)) where each +glyph is ((\"glyph\" . G) (\"name\" . ICON-NAME)). Within a face, icons are +deduplicated by name and sorted by name. An entry without a :face, an +unresolvable glyph, or a face with no native color is skipped." + (let ((table (make-hash-table :test 'eq)) + (seen (make-hash-table :test 'equal)) + (order nil)) + (dolist (sym cj/--nerd-icons-gallery-alists) + (dolist (e (and (boundp sym) (symbol-value sym))) + (let* ((face (plist-get (nthcdr 3 e) :face)) + (name (nth 2 e)) + (glyph (cj/--nerd-icons-legend-glyph (nth 1 e) name))) + (when (and face glyph (stringp name)) + (let ((dk (concat (symbol-name face) "\0" name))) + (unless (gethash dk seen) + (puthash dk t seen) + (unless (gethash face table) (push face order)) + (puthash face + (cons (list (cons "glyph" glyph) (cons "name" name)) + (gethash face table)) + table))))))) + (let ((groups + (delq nil + (mapcar (lambda (face) + (let ((hsl (cj/--nerd-icons-face-hsl face)) + (glyphs (sort (gethash face table) + (lambda (a b) (string< (cdr (assoc "name" a)) + (cdr (assoc "name" b))))))) + (when hsl (list face (nth 0 hsl) (nth 2 hsl) glyphs)))) + (nreverse order))))) + (setq groups (sort groups (lambda (a b) + (if (/= (nth 1 a) (nth 1 b)) + (< (nth 1 a) (nth 1 b)) + (> (nth 2 a) (nth 2 b)))))) + (mapcar (lambda (g) + (list (cons "face" (symbol-name (nth 0 g))) + (cons "hue" (nth 1 g)) + (cons "glyphs" (apply #'vector (nth 3 g))))) + groups)))) + +;; ---- entry point ---------------------------------------------------------- + +(defun cj/nerd-icons-write-legend () + "Resolve the legend + gallery from the live nerd-icons alists and write +nerd-icons-legend.json next to this file. Requires nerd-icons (loaded here, not +at file load, so the capture functions stay unit-testable without it)." + (require 'nerd-icons) + (let ((legend (cj/--nerd-icons-legend-rows)) + (gallery (cj/--nerd-icons-gallery-groups))) + (with-temp-file (expand-file-name + "nerd-icons-legend.json" + (file-name-directory (or load-file-name buffer-file-name + "~/.emacs.d/scripts/theme-studio/"))) + (let ((json-encoding-pretty-print t)) + (insert (json-encode (list (cons "legend" (apply #'vector legend)) + (cons "gallery" (apply #'vector gallery)))) + "\n"))) + (message "nerd-icons-legend: wrote %d legend rows, %d gallery groups (%d glyphs)" + (length legend) (length gallery) + (apply #'+ (mapcar (lambda (g) (length (cdr (assoc "glyphs" g)))) gallery))))) (provide 'build-nerd-icons-legend) ;;; build-nerd-icons-legend.el ends here diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 6053aa62f..09c25d804 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -13,15 +13,18 @@ def read_json(name): return json.loads(read_text(name)) NERD_ICONS_LEGEND_FIELDS = ("key", "label", "face", "category", "glyph") +NERD_ICONS_GALLERY_GLYPH_FIELDS = ("glyph", "name") def load_nerd_icons_legend(path=None): """Return the nerd-icons legend rows, or None when the artifact is unusable. The legend is captured by build-nerd-icons-legend.el into nerd-icons-legend.json. - Absent, malformed, empty, or carrying a row without all five string fields - (key/label/face/category/glyph) -> None, with a warning, so the caller falls - back to the generic nerd-icons app instead of erroring. nerd-icons not being - installed at capture time yields an empty/absent file, which lands here as None. + The artifact is a JSON object {legend, gallery}; a legacy bare array is read as + the legend directly (back-compat). Absent, malformed, empty, or carrying a row + without all five string fields (key/label/face/category/glyph) -> None, with a + warning, so the caller falls back to the generic nerd-icons app instead of + erroring. nerd-icons not being installed at capture time yields an empty/absent + file, which lands here as None. """ path = path or os.path.join(HERE, "nerd-icons-legend.json") if not os.path.exists(path): @@ -29,10 +32,11 @@ def load_nerd_icons_legend(path=None): return None try: with open(path) as src: - rows = json.load(src) + data = json.load(src) except (json.JSONDecodeError, OSError) as exc: print(f"WARNING: nerd-icons legend malformed ({path}: {exc}); generic nerd-icons app") return None + rows = data.get("legend") if isinstance(data, dict) else data if not isinstance(rows, list) or not rows: print(f"WARNING: nerd-icons legend empty ({path}); generic nerd-icons app") return None @@ -44,6 +48,44 @@ def load_nerd_icons_legend(path=None): return None return rows +def load_nerd_icons_gallery(path=None): + """Return the nerd-icons gallery groups, or None when absent/unusable. + + The gallery (the full colored catalog) rides nerd-icons-legend.json under the + "gallery" key: a list of {face, hue, glyphs:[{glyph,name}]} groups captured by + build-nerd-icons-legend.el, one group per color face, ordered by hue. A legacy + array-only artifact (legend, no gallery), an absent/malformed file, or a + structurally invalid group -> None, so the caller simply omits the gallery while + the legend data still loads. Never raises. + """ + path = path or os.path.join(HERE, "nerd-icons-legend.json") + if not os.path.exists(path): + print(f"WARNING: nerd-icons gallery absent ({path}); legend without gallery") + return None + try: + with open(path) as src: + data = json.load(src) + except (json.JSONDecodeError, OSError) as exc: + print(f"WARNING: nerd-icons gallery malformed ({path}: {exc}); legend without gallery") + return None + groups = data.get("gallery") if isinstance(data, dict) else None + if not isinstance(groups, list) or not groups: + return None # legacy/array-only artifact: legend present, no gallery — not an error + for group in groups: + if not (isinstance(group, dict) + and isinstance(group.get("face"), str) and group["face"].startswith("nerd-icons-") + and isinstance(group.get("hue"), (int, float)) + and isinstance(group.get("glyphs"), list) and group["glyphs"]): + print(f"WARNING: nerd-icons gallery group invalid ({group!r}); legend without gallery") + return None + for entry in group["glyphs"]: + if not (isinstance(entry, dict) + and all(isinstance(entry.get(f), str) and entry.get(f) + for f in NERD_ICONS_GALLERY_GLYPH_FIELDS)): + print(f"WARNING: nerd-icons gallery glyph invalid ({entry!r}); legend without gallery") + return None + return groups + def strip_exports(src): """Drop ES-module `export`/`import` lines so the body loads as a classic