diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-24 14:44:28 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-24 16:15:24 -0400 |
| commit | fa5b28ea69f3bff0941f8a097a9746b7a67fa900 (patch) | |
| tree | 71571b286b77b9168de3308f50877ad7f6fa4854 /scripts/theme-studio/app.js | |
| parent | c11ad211f5d72b6ee2b48d80f25d16e3e85248eb (diff) | |
| download | dotemacs-fa5b28ea69f3bff0941f8a097a9746b7a67fa900.tar.gz dotemacs-fa5b28ea69f3bff0941f8a097a9746b7a67fa900.zip | |
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.
Diffstat (limited to 'scripts/theme-studio/app.js')
| -rw-r--r-- | scripts/theme-studio/app.js | 61 |
1 files changed, 53 insertions, 8 deletions
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)=>`<option value="${i}">${esc(pn.label)}</option>`).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';} |
