쿼리 편집기 — 한글에서 색과 글자가 어긋나던 문제, 다크 제목 표시줄, 목업 맞춤

■ 한글을 치면 색칠이 글자와 어긋났다 (가장 중요)

색칠 레이어가 글자 위치를 <b>직접 재서</b> 잡고 있었다. 앞부분을 FormattedText 로 측정해
그 폭만큼 오른쪽으로 옮겨 그리는 방식이다. ASCII 에서는 맞았지만 한글에서 무너졌다 —
편집 글꼴(Consolas)에 한글 글리프가 없어 폰트 대체가 일어나는데,
TextBox 의 내부 조판과 이쪽 측정이 같은 글꼴·같은 셰이핑을 고르리라는 보장이 없다.
조금만 달라도 뒤로 갈수록 벌어져 색과 글자가 따로 논다.

이제 <b>TextBox 에게 좌표를 묻는다</b>(GetRectFromCharacterIndex).
여백·스크롤·줄 높이·폰트 대체가 전부 TextBox 기준으로 이미 반영돼 오고,
조각마다 위치를 새로 물으므로 오차가 누적되지 않는다.
줄 높이를 손으로 계산하던 코드도 함께 사라졌다.
편집 글꼴 순서도 한글이 있는 것(D2Coding → 굴림체 → Consolas)으로 바꿔 대체 자체를 줄였다.

--query-popup 진단에 정렬 검사 2건을 넣었다: 한글이 섞인 줄에서 글자 좌표가 뒤로 밀리지 않는가,
마지막 글자 다음 좌표가 캐럿 자리와 이어지는가. 현재 9/9.

■ 다크 모드인데 제목 표시줄만 밝게 남던 문제

ThemedWindow 스타일은 창 <b>안쪽</b>만 칠한다. 제목 표시줄은 OS 가 그리고
기본값은 Windows 의 테마를 따르므로, 앱을 다크로 바꿔도 Windows 가 라이트면 흰 띠가 남는다.

DWM 속성(DWMWA_USE_IMMERSIVE_DARK_MODE)으로 창별로 지정한다. 커스텀 크롬을 직접 그리는 것보다
훨씬 싸고, 최소화·최대화·닫기의 동작과 접근성이 OS 것 그대로 남는다.
창마다 손으로 넣으면 새 대화상자에서 반드시 빠지므로 App 에서 Window 클래스 핸들러로 한 번만 걸었다 —
지금 있는 창과 앞으로 만들 창 모두 자동으로 적용된다.
테마를 바꾸는 순간 이미 떠 있는 창들도 함께 맞춘다(ThemeManager.ApplyChromeToOpenWindows).

■ 목업과 위치 맞춤

앞 배지(아이콘) · 검색칸의 돋보기와 안내 문구 · 안내 상자의 ⓘ 를 넣었다.
갈래 칩 순서는 개수순에서 <b>고정 순서</b>(환자·서식·작업자·컨트롤)로 바꿨다 —
컨트롤 수는 서식마다 달라서 개수순으로 두면 서식을 바꿀 때마다 칩이 자리를 바꿔
손이 기억하지 못한다.

회귀: 테스트 230/230, 편집 스모크 실패 0, 팝업·정렬 점검 9/9, 검증 실행 점검 10/10,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 16:05:33 +09:00
co-authored by Claude Opus 5
parent 68fc01c6ba
commit 3e26af8739
7 changed files with 224 additions and 94 deletions
@@ -146,104 +146,87 @@ public sealed class SqlHighlightLayer : FrameworkElement
var tokens = SqlTokenizer.Tokenize(text);
var typeface = new Typeface(source.FontFamily, source.FontStyle, source.FontWeight, source.FontStretch);
var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
// TextBox 의 내부 여백 + 스크롤 오프셋만큼 옮겨 그린다
var offsetX = source.Padding.Left + source.BorderThickness.Left - (scroller?.HorizontalOffset ?? 0);
var offsetY = source.Padding.Top + source.BorderThickness.Top - (scroller?.VerticalOffset ?? 0);
// 줄 높이는 실제 글꼴에서 잰다 — 상수로 두면 글꼴이 바뀔 때 어긋난다
var probe = new FormattedText("Ag", CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
typeface, source.FontSize, Brushes.Black, dpi);
var lineHeight = probe.Height;
// 커서가 있는 줄 — 긴 쿼리에서 지금 어디를 고치고 있는지 잃지 않게 옅은 띠를 깐다
var caretLine = LineIndexOf(text, source.CaretIndex);
var lineIndex = 0;
var lineStart = 0;
var height = ActualHeight;
while (lineStart <= text.Length)
// 커서가 있는 줄 — 긴 쿼리에서 지금 어디를 고치고 있는지 잃지 않게 옅은 띠를 깐다.
// 위치는 TextBox 에게 묻는다(아래 이유와 같다).
if (source.SelectionLength == 0 && TryFindResource("B.Hover") is Brush band)
{
var newline = text.IndexOf('\n', lineStart);
var lineEnd = newline < 0 ? text.Length : newline;
var y = offsetY + lineIndex * lineHeight;
if (lineIndex == caretLine && y + lineHeight >= 0 && y <= height
&& source.SelectionLength == 0
&& TryFindResource("B.Hover") is Brush band)
var caretRect = RectAt(source.CaretIndex);
if (caretRect is { } cr && cr.Bottom >= 0 && cr.Top <= height)
{
dc.DrawRectangle(band, null, new Rect(0, y, Math.Max(0, ActualWidth), lineHeight));
dc.DrawRectangle(band, null, new Rect(0, cr.Top, Math.Max(0, ActualWidth), cr.Height));
}
// 화면 밖 줄은 그리지 않는다 — 긴 쿼리에서 매 입력마다 전부 그리면 눈에 띄게 느려진다
if (y > height)
{
break;
}
if (y + lineHeight >= 0)
{
DrawLine(dc, text, tokens, lineStart, lineEnd, typeface, dpi, offsetX, y);
}
if (newline < 0)
{
break;
}
lineStart = newline + 1;
lineIndex++;
}
}
/// <summary>주어진 위치가 몇 번째 줄인지(0부터)</summary>
private static int LineIndexOf(string text, int index)
{
var line = 0;
var upto = Math.Clamp(index, 0, text.Length);
for (var i = 0; i < upto; i++)
foreach (var token in tokens)
{
if (text[i] == '\n')
// 조각이 줄을 걸치면 줄마다 나눠 그린다 — 한 번에 그리면 줄바꿈이 무시된다
var pieceStart = token.Start;
while (pieceStart < token.End)
{
line++;
var newline = text.IndexOf('\n', pieceStart, token.End - pieceStart);
var pieceEnd = newline < 0 ? token.End : newline;
if (pieceEnd > pieceStart)
{
DrawPiece(dc, text, pieceStart, pieceEnd, token.Kind, typeface, dpi, height);
}
if (newline < 0)
{
break;
}
pieceStart = newline + 1;
}
}
return line;
}
/// <summary>
/// 한 줄을 조각별 색으로 그린다.
/// 조각 경계마다 <see cref="FormattedText"/> 를 새로 만들되, x 위치는 <b>앞부분 전체를 다시 재서</b> 잡는다 —
/// 폭을 누적하면 자간·힌팅 때문에 뒤로 갈수록 한두 픽셀씩 밀린다.
/// 그 글자의 화면 위치 — <b>TextBox 에게 직접 묻는다.</b>
///
/// 예전에는 앞부분 글자 폭을 이쪽에서 재서 x 를 계산했는데, 한글이 들어오면 어긋났다.
/// 편집기 글꼴(Consolas)에는 한글 글리프가 없어 폰트 대체가 일어나는데,
/// TextBox 의 내부 조판과 이쪽 <see cref="FormattedText"/> 측정이 같은 글꼴·같은 셰이핑을 고르리라는
/// 보장이 없다. 조금만 달라도 뒤로 갈수록 벌어져 색과 글자가 따로 논다.
///
/// 좌표를 물어보면 여백·스크롤·줄 높이·폰트 대체가 전부 TextBox 기준으로 이미 반영돼 온다.
/// </summary>
private void DrawLine(DrawingContext dc, string text, IReadOnlyList<SqlToken> tokens,
int lineStart, int lineEnd, Typeface typeface, double dpi, double offsetX, double y)
private Rect? RectAt(int index)
{
foreach (var token in tokens)
if (source is null)
{
if (token.End <= lineStart || token.Start >= lineEnd)
{
continue;
}
var start = Math.Max(token.Start, lineStart);
var end = Math.Min(token.End, lineEnd);
if (end <= start)
{
continue;
}
var prefix = text[lineStart..start];
var x = offsetX;
if (prefix.Length > 0)
{
var measured = new FormattedText(prefix, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
typeface, source!.FontSize, Brushes.Black, dpi);
x += measured.WidthIncludingTrailingWhitespace;
}
var piece = new FormattedText(text[start..end], CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
typeface, source!.FontSize, BrushFor(token.Kind), dpi);
dc.DrawText(piece, new Point(x, y));
return null;
}
var clamped = Math.Clamp(index, 0, source.Text.Length);
var rect = source.GetRectFromCharacterIndex(clamped);
if (rect.IsEmpty || double.IsInfinity(rect.Top) || double.IsNaN(rect.Top))
{
return null;
}
return rect;
}
/// <summary>
/// 한 줄 안의 조각 하나를 그 자리에 그린다.
///
/// 시작 위치를 TextBox 에게 물으므로 조각마다 오차가 초기화된다 —
/// 폭을 누적하던 예전 방식과 달리 뒤로 갈수록 밀리지 않는다.
/// </summary>
private void DrawPiece(DrawingContext dc, string text, int start, int end,
SqlTokenKind kind, Typeface typeface, double dpi, double height)
{
if (RectAt(start) is not { } rect)
{
return;
}
// 화면 밖은 그리지 않는다 — 긴 쿼리에서 매 입력마다 전부 그리면 눈에 띄게 느려진다
if (rect.Bottom < 0 || rect.Top > height)
{
return;
}
var piece = new FormattedText(text[start..end], CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
typeface, source!.FontSize, BrushFor(kind), dpi);
dc.DrawText(piece, new Point(rect.X, rect.Y));
}
#endregion
}