Skip to content

Commit 8297606

Browse files
abbayeclaude
andcommitted
fix(CodeEditor): ClearType blur on embedded language zones
Replace dual-pass render (gap-only base + token overdraw) with a single unified orderedTokens list that interleaves EditorForeground gap tokens into the sorted syntax token stream. Root cause: the gap pass and token pass each independently computed SnapToPixelPublic(ComputeVisualX(...)) for adjacent characters. At gap/token boundaries, sub-pixel rounding produced slightly different x-positions for the same character → ClearType composited two overlapping glyph edges → visible blur on lines with long JS/CSS embedded tokens. Fix: build orderedTokens once (gap tokens + syntax tokens in column order), then render in a single loop and reuse the same list for the GlyphRun cache build. Every character is drawn exactly once through the same x-positioning path. Note: HTML.whfmt embeddedLanguages rules and EmbeddedRangeClassifier were correct — the blur was purely a rendering pipeline issue. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 78488ef commit 8297606

1 file changed

Lines changed: 30 additions & 58 deletions

File tree

Sources/Editors/WpfHexEditor.Editor.CodeEditor/Controls/CodeEditor.Rendering.cs

Lines changed: 30 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2822,44 +2822,39 @@ private void RenderTextContent(DrawingContext dc)
28222822
? y + _glyphRenderer.Baseline
28232823
: y + _charHeight * 0.8;
28242824

2825-
// Base pass (external highlighter only): draw only the character spans NOT
2826-
// covered by any syntax token in EditorForeground, so identifiers/punctuation
2827-
// remain visible without double-drawing covered spans.
2828-
// Double-drawing the same characters (base + token overdraw) causes ClearType
2829-
// sub-pixel blurring, especially on long string literals in embedded zones.
2830-
if (hasExternalHighlighter)
2825+
// Build the final ordered token list for this line.
2826+
// For lines with an external highlighter, inject EditorForeground gap tokens
2827+
// for any character spans not covered by syntax tokens so every character is
2828+
// rendered exactly once through the same code path. This avoids the ClearType
2829+
// blur that results from drawing base-pass fragments and syntax tokens at
2830+
// independently-snapped x positions for the same character.
2831+
IReadOnlyList<Helpers.SyntaxHighlightToken> orderedTokens;
2832+
if (hasExternalHighlighter && renderTokens.Count > 0)
28312833
{
2832-
var dpi2 = VisualTreeHelper.GetDpi(this).PixelsPerDip;
2833-
int gapCursor = 0;
2834+
var filled = new List<Helpers.SyntaxHighlightToken>(renderTokens.Count + 4);
2835+
int gc = 0;
28342836
foreach (var tok in renderTokens.OrderBy(t => t.StartColumn))
28352837
{
2836-
int tokStart = Math.Max(0, tok.StartColumn);
2837-
int tokEnd = Math.Min(line.Text.Length, tok.StartColumn + tok.Length);
2838-
if (tokStart > gapCursor)
2839-
{
2840-
// Gap before this token — draw in default foreground.
2841-
var gap = line.Text.Substring(gapCursor, tokStart - gapCursor);
2842-
var gapTk = new Helpers.SyntaxHighlightToken(gapCursor, gap.Length, gap, EditorForeground);
2843-
double gapX = _glyphRenderer?.SnapToPixelPublic(x + _glyphRenderer.ComputeVisualX(line.Text, gapCursor))
2844-
?? x + gapCursor * _charWidth;
2845-
if (_glyphRenderer != null) _glyphRenderer.RenderToken(dc, gapTk, gapX, y, baselineY);
2846-
else dc.DrawText(new FormattedText(gap, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, _typeface, _fontSize, EditorForeground, dpi2), new Point(gapX, y));
2847-
}
2848-
gapCursor = Math.Max(gapCursor, tokEnd);
2849-
}
2850-
// Trailing gap after last token.
2851-
if (gapCursor < line.Text.Length)
2852-
{
2853-
var tail = line.Text[gapCursor..];
2854-
var tailTk = new Helpers.SyntaxHighlightToken(gapCursor, tail.Length, tail, EditorForeground);
2855-
double tailX = _glyphRenderer?.SnapToPixelPublic(x + _glyphRenderer.ComputeVisualX(line.Text, gapCursor))
2856-
?? x + gapCursor * _charWidth;
2857-
if (_glyphRenderer != null) _glyphRenderer.RenderToken(dc, tailTk, tailX, y, baselineY);
2858-
else dc.DrawText(new FormattedText(tail, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, _typeface, _fontSize, EditorForeground, dpi2), new Point(tailX, y));
2838+
int ts = Math.Max(0, tok.StartColumn);
2839+
int te = Math.Min(line.Text.Length, tok.StartColumn + tok.Length);
2840+
if (ts > gc)
2841+
filled.Add(new Helpers.SyntaxHighlightToken(gc, ts - gc,
2842+
line.Text.Substring(gc, ts - gc), EditorForeground));
2843+
if (te > ts)
2844+
filled.Add(tok);
2845+
gc = Math.Max(gc, te);
28592846
}
2847+
if (gc < line.Text.Length)
2848+
filled.Add(new Helpers.SyntaxHighlightToken(gc, line.Text.Length - gc,
2849+
line.Text[gc..], EditorForeground));
2850+
orderedTokens = filled;
2851+
}
2852+
else
2853+
{
2854+
orderedTokens = renderTokens;
28602855
}
28612856

2862-
foreach (var token in renderTokens)
2857+
foreach (var token in orderedTokens)
28632858
{
28642859
// Use tab-aware X so tokens on tab-indented lines are not shifted left.
28652860
// Snap to physical pixel: fractional charWidth × column accumulates sub-pixel
@@ -2902,34 +2897,11 @@ private void RenderTextContent(DrawingContext dc)
29022897
}
29032898

29042899
// ── P1-CE-05: Build GlyphRun cache after first render ─────────────
2905-
// Cache gap tokens + syntax tokens so the stale-cache path (DrawGlyphRun)
2906-
// renders each character exactly once — matching the gap-only live pass.
2900+
// orderedTokens already contains gap-fill tokens, so use it directly
2901+
// for the cache build — every character is covered exactly once.
29072902
if (_glyphRenderer != null)
29082903
{
2909-
IEnumerable<Helpers.SyntaxHighlightToken> allCacheTokens;
2910-
if (hasExternalHighlighter)
2911-
{
2912-
// Build gap segments (chars not covered by any syntax token).
2913-
var gapTokens = new List<Helpers.SyntaxHighlightToken>();
2914-
int gc = 0;
2915-
foreach (var tok in renderTokens.OrderBy(t => t.StartColumn))
2916-
{
2917-
int ts2 = Math.Max(0, tok.StartColumn);
2918-
int te2 = Math.Min(line.Text.Length, tok.StartColumn + tok.Length);
2919-
if (ts2 > gc)
2920-
gapTokens.Add(new Helpers.SyntaxHighlightToken(gc, ts2 - gc, line.Text.Substring(gc, ts2 - gc), EditorForeground));
2921-
gc = Math.Max(gc, te2);
2922-
}
2923-
if (gc < line.Text.Length)
2924-
gapTokens.Add(new Helpers.SyntaxHighlightToken(gc, line.Text.Length - gc, line.Text[gc..], EditorForeground));
2925-
allCacheTokens = Enumerable.Concat(gapTokens, renderTokens);
2926-
}
2927-
else
2928-
{
2929-
allCacheTokens = renderTokens;
2930-
}
2931-
2932-
line.GlyphRunCache = _glyphRenderer.BuildLineGlyphRuns(allCacheTokens, SyntaxUrlColor, line.Text);
2904+
line.GlyphRunCache = _glyphRenderer.BuildLineGlyphRuns(orderedTokens, SyntaxUrlColor, line.Text);
29332905
line.IsGlyphCacheDirty = false;
29342906

29352907
// Cache link zones for GlyphRun-hit renders (no re-run of OverlayUrlTokens).

0 commit comments

Comments
 (0)