From 126a4e977cbde3b4e91823cd1ea126d054a51be1 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 9 Jun 2026 09:41:28 -0500 Subject: fix(theme-studio): make the live buffer preview render UI faces the way Emacs does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mock buffer rendered several faces wrong, verified against the running daemon and emacs -Q: - highlight was flat monochrome text. Like region, the highlight face is background-only, so Emacs lets the syntax foreground show through (face merge: highlight over a keyword keeps the keyword color, adds the highlight background). Both now go through one overlay helper that keeps token colors and only overrides the foreground when the face sets one. - cursor was an empty block at end-of-line. Emacs draws a box cursor on the character at point, in the frame background over the cursor color — now it sits on a real glyph. - the overlay faces, the line-number gutter, and the paren faces ignored weight/underline/etc. They now honor bold/italic/underline/strike, matching the table preview. - the fringe showed only its background. It now draws a continuation indicator in the fringe foreground. - the mode line had no box. It now carries the 3D released-button box that is the Emacs default. Defaults seeded to the theme-independent Emacs core styles (from emacs -Q): error/warning/success are bold; lazy-highlight and show-paren-match are underlined (link already was). Added a #mocktest gate pinning these — overlay faces keep token colors, the cursor is on a glyph, styles render, the fringe indicator is present, the mode line has its box. Verified it goes red when the rendering regresses. --- scripts/theme-studio/app.js | 82 ++++++++++++++++++++++++++------- scripts/theme-studio/generate.py | 6 ++- scripts/theme-studio/run-tests.sh | 2 +- scripts/theme-studio/theme-studio.html | 84 ++++++++++++++++++++++++++-------- 4 files changed, 137 insertions(+), 37 deletions(-) diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index c5a618e3..450183e0 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -324,34 +324,61 @@ function buildMockFrame(){ {t:[['punc','('],['kw','defun'],['p',' '],['fnd','cj/greet'],['p',' '],['punc','('],['var','name'],['punc',')']]}, {t:[['p',' '],['punc','('],['fnc','message'],['p',' '],['str','"hi %s"'],['p',' '],['var','name'],['punc','))']],cur:1}, {t:[['p',' '],['punc','('],['kw','setq'],['p',' '],['var','count'],['p',' '],['num','42'],['punc',')']],region:1}, - {plain:' (if (> count 0)',match:1}, - {plain:' (setq total (+ total count))',hl:1}, - {t:[['p',' '],['punc','('],['fnc','process'],['p',' '],['var','items'],['punc',')']]}, - {plain:' (cl-incf count)',lazy:1}, + {t:[['p',' '],['punc','('],['kw','if'],['p',' '],['punc','('],['op','>'],['p',' '],['var','count'],['p',' '],['num','0'],['punc',')']],match:1}, + {t:[['p',' '],['punc','('],['kw','setq'],['p',' '],['var','total'],['p',' '],['punc','('],['op','+'],['p',' '],['var','total'],['p',' '],['var','count'],['punc','))']],hl:1}, + {t:[['p',' '],['punc','('],['fnc','process'],['p',' '],['var','items'],['punc',')']],cont:1}, + {t:[['p',' '],['punc','('],['fnc','cl-incf'],['p',' '],['var','count'],['punc',')']],lazy:1}, {t:[['p',' '],['punc','('],['kw','setq'],['p',' '],['var','done'],['p',' '],['con','t'],['punc',')']],paren:1}, - {plain:' (oops nested))',mismatch:1} + {t:[['p',' '],['punc','('],['fnc','oops'],['p',' '],['var','nested'],['punc','))']],mismatch:1} ]; + // An overlay face (region, highlight, isearch, lazy-highlight) merges the way + // Emacs does: its background applies, and its foreground overrides the tokens + // only when set — otherwise the underlying syntax colors show through. + const overlay=(tokens,face,dface)=>{ + const inner=face.fg + ? `${tokens.map(([,t])=>esc(t)).join('')}` + : tokens.map(([k,t])=>mockSpan(k,t)).join(''); + return `${inner}`; + }; + // Emacs box cursor: it sits on the character at point, drawn in the frame + // background over the cursor color (the cursor face's foreground is ignored). + // Falls back to a trailing block only if the line has no glyph (point at EOL). + const withCursor=(tokens)=>{ + let out='',placed=false; + const cell=ch=>`${esc(ch)}`; + for(const [k,t] of tokens){ + const m=placed?-1:t.search(/\S/); + if(m>=0){ + if(m>0)out+=mockSpan(k,t.slice(0,m)); + out+=cell(t[m]); + if(t.length>m+1)out+=mockSpan(k,t.slice(m+1)); + placed=true; + } else out+=mockSpan(k,t); + } + if(!placed)out+=cell(' '); + return out; + }; let buf=''; lines.forEach((L,i)=>{ const isc=L.cur; const nFg=isc?(lnc.fg||fg):(ln.fg||fg), nBg=isc?(lnc.bg||'transparent'):(ln.bg||'transparent'); const rowBg=isc?(hl.bg||'transparent'):'transparent'; let cd; - if(L.plain){ - if(L.match)cd=`${esc(L.plain)}`; - else if(L.lazy)cd=`${esc(L.plain)}`; - else if(L.hl)cd=`${esc(L.plain)}`; - else if(L.mismatch)cd=esc(L.plain.slice(0,-1))+`${esc(L.plain.slice(-1))}`; - else cd=esc(L.plain); - } else if(L.paren){cd=L.t.map(([k,t],j)=>j===L.t.length-1?`${esc(t)}`:mockSpan(k,t)).join('');} - else{cd=L.t.map(([k,t])=>mockSpan(k,t)).join('');if(L.region)cd=`${cd}`;} - if(isc)cd+=` `; + if(isc)cd=withCursor(L.t); + else if(L.region)cd=overlay(L.t,reg,'region'); + else if(L.hl)cd=overlay(L.t,hil,'highlight'); + else if(L.match)cd=overlay(L.t,isr,'isearch'); + else if(L.lazy)cd=overlay(L.t,laz,'lazy-highlight'); + else if(L.paren)cd=L.t.map(([k,t],j)=>j===L.t.length-1?`${esc(t)}`:mockSpan(k,t)).join(''); + else if(L.mismatch)cd=L.t.map(([k,t],j)=>{if(j!==L.t.length-1)return mockSpan(k,t);const head=t.slice(0,-1),bad=t.slice(-1);return (head?mockSpan(k,head):'')+`${esc(bad)}`;}).join(''); + else cd=L.t.map(([k,t])=>mockSpan(k,t)).join(''); const nFace=isc?'line-number-current-line':'line-number'; - buf+=`
${i+1}${cd||' '}
`; + buf+=`
${L.cont?'↪':''}${i+1}${cd||' '}
`; }); let html=`
${buf}
`; - html+=`
init.el (Emacs Lisp) L5 git:main
`; - html+=`
*Messages* (Fundamental)
`; + const mlbox='box-shadow:inset 1px 1px 0 #ffffff33,inset -1px -1px 0 #00000066'; // 3D released-button box, the Emacs mode-line default + html+=`
init.el (Emacs Lisp) L5 git:main
`; + html+=`
*Messages* (Fundamental)
`; html+=`
I-search: count zzz [no match]
`; html+=`
https://gnu.org error warning ok
`; fr.innerHTML=html;fr.style.background=bg;fr.style.color=fg; @@ -792,6 +819,27 @@ if(location.hash==='#sorttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c buildPkgTable();srtTable('pkgbody',2);A(asc(ddVals('pkgbody')),'pkgbody-fg-asc'); document.title='SORTTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='sorttest';d.textContent='SORTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Live-buffer rendering gate (open with #mocktest): pins the face-faithfulness +// fixes so they cannot silently regress — overlay faces keep syntax colors and +// honor their styles, the cursor sits on a glyph, line numbers honor weight, the +// fringe shows its foreground indicator, and the mode-line carries its box. +if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const Q=s=>document.querySelector('#mockframe '+s); + buildMockFrame(); + A(Q('[data-face="highlight"] [data-k]'),'highlight-keeps-token-colors'); + A(Q('[data-face="region"] [data-k]'),'region-keeps-token-colors'); + const curCell=Q('[data-face="cursor"]'); + A(curCell&&curCell.textContent.trim().length===1,'cursor-on-glyph'); + const laz=Q('[data-face="lazy-highlight"]'); + A(laz&&/underline/.test(laz.getAttribute('style')||''),'overlay-honors-style'); + A([...document.querySelectorAll('#mockframe .fr')].some(e=>e.textContent.trim()),'fringe-indicator-present'); + const mlbar=Q('[data-face="mode-line"]'); + A(mlbar&&/box-shadow/.test(mlbar.getAttribute('style')||''),'mode-line-box'); + UIMAP['line-number-current-line'].bold=true;buildMockFrame(); + const curNum=Q('[data-face="line-number-current-line"]'); + A(curNum&&/font-weight:\s*bold/.test(curNum.getAttribute('style')||''),'line-number-honors-weight'); + document.title='MOCKTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='mocktest';d.textContent='MOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} if(location.hash.startsWith('#pick')){openPicker();const m=location.hash.slice(5);if(m){const b=document.querySelector('.pmode button[data-m="'+m+'"]');if(b)b.click();}} if(location.hash==='#cursortest'){document.getElementById('newhexstr').value='#67809c';openPicker();const sc=document.getElementById('svcur'),hc=document.getElementById('huecur');const L=parseFloat(sc.style.left||'0'),T=parseFloat(sc.style.top||'0'),H=parseFloat(hc.style.top||'0');const ok=L>1&&T>1&&H>1;document.title='CURSORTEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='cursortest';d.textContent='CURSORTEST '+(ok?'PASS':'FAIL')+' left='+sc.style.left+' top='+sc.style.top+' hue='+hc.style.top;document.body.appendChild(d);} if(location.hash.startsWith('#app')){const ap=location.hash.slice(4),s=document.getElementById('appsel');if(s&&ap){s.value=ap;pkgChanged();}} diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 56aa5800..29a2ebac 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -88,8 +88,12 @@ if _seed: if _d.get('ui'): for _k,_v in _d['ui'].items(): UIMAP[_k]=_v if 'locks' in _d: LOCKS=_d['locks'] -# link is underlined by default (matches the built-in link face). +# These faces carry a fixed style in Emacs's built-in definitions (verified with +# emacs -Q), independent of any theme: link / lazy-highlight / show-paren-match +# are underlined; error / warning / success are bold. Seed the defaults to match. 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 # Tier-3 package faces (Phase 2): complete own-defface sets for org/magit/elfeed, # built from face-name lists + a curated seed-color map. Prominent faces are # seeded; the long tail seeds to the default foreground for the user to tune. diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh index 42d24960..e364d431 100755 --- a/scripts/theme-studio/run-tests.sh +++ b/scripts/theme-studio/run-tests.sh @@ -53,7 +53,7 @@ CHROME="" for c in google-chrome-stable google-chrome chromium chromium-browser; do if command -v "$c" >/dev/null 2>&1; then CHROME="$c"; break; fi done -HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest" +HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest" if [ "$NO_BROWSER" = 1 ]; then skip_msg "browser hash gates (--no-browser)" elif [ -z "$CHROME" ]; then diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 0e04d012..db5cac7f 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -174,7 +174,7 @@