쿼리 편집기 — 서드파티 없이 만든 구문 강조와 편집 보조

배포본에 서드파티 편집기 DLL 을 넣지 않기로 해서 직접 만들었다.

■ 색칠 방식 — 투명 TextBox 뒤에 그린다

RichTextBox 로 직접 칠하면 입력할 때마다 서식을 다시 입혀야 해서 실행취소 묶음이 깨지고
IME 조합 중 깜빡인다. 대신 편집은 평범한 TextBox 가 그대로 하고(캐럿·선택·실행취소·IME·스크롤이
전부 공짜다) 글자만 투명하게 해서, 뒤에 깔린 SqlHighlightLayer 가 같은 자리에 색을 입혀 그린다.
맞춰야 하는 것은 하나뿐이다 — 글꼴·크기·여백·줄바꿈 설정이 TextBox 와 완전히 같을 것.

성능은 화면에 보이는 줄만 그려서 잡았다. x 위치는 앞부분을 다시 재서 잡는다 —
조각 폭을 누적하면 자간·힌팅 때문에 뒤로 갈수록 한두 픽셀씩 밀린다.

■ 조각내기(SqlTokenizer) — Core 에 순수 함수로

예약어 · 내장함수 · 숫자 · 문자열 · 주석 · 치환변수, 그리고 Broken.
Broken 을 따로 둔 것이 요점이다. 이 쿼리는 EMR 런타임에서 돌고 실패해도 빈 catch 가 삼켜
화면에 아무 표시가 없다. 닫히지 않은 따옴표나 << >> 는 편집기에서 즉시 눈에 띄어야 한다.
Oracle 방언 기준이고, '' 이스케이프를 문자열 끊김으로 오인하지 않는다.
줄바꿈을 넘긴 문자열은 닫는 따옴표를 빠뜨린 것으로 본다 — 안 그러면 뒤 문장 전체가 문자열로 물든다.

조각은 겹치지 않고 전 구간을 덮는다(빈틈이 있으면 그 글자가 안 그려진다). 테스트로 고정했다.

■ 편집 보조

줄 번호 · 글자/줄 수 · Tab·Shift+Tab 들여쓰기(선택 줄 단위) · Enter 자동 들여쓰기 ·
Ctrl+/ 주석 토글(섞인 상태면 전부 붙인다 — 토글이 예측 가능해진다).

저장 전 경고 두 가지를 하단에 띄운다: 닫히지 않은 조각, 그리고 카탈로그에 없는 치환 변수.
후자는 접두어를 임의로 줄여 쓴 경우를 잡는다 — 런타임은 그걸 치환하지 못하고
<<...>> 를 SQL 에 그대로 남겨 ORA 오류를 내며, 그 데이터소스를 참조하는 컨트롤이 전부 공백이 된다.

■ 치환 변수 패널

이름·설명·초성으로 좁히는 검색을 붙였다(ㅊㅌㅂㅎ → 차트번호). Enter 로도 삽입된다.
변수 목록 자체의 전량 이식은 별도 커밋으로 이어간다 — 현재 25개는 레거시 surface 의 절반이다.

SQL 이 아닌 용도(상용구 문구)로 재사용할 때는 색칠을 끈다.
예약어와 겹치는 낱말이 엉뚱하게 물들면 안 된다.

단위 테스트 12건 추가(전 구간 덮기·대소문자·식별자 속 예약어 오검출·'' 이스케이프·
변수 통째 인식·닫히지 않은 조각 2종·줄 넘긴 문자열).
회귀: 테스트 174/174, 편집 스모크 실패 0,
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 14:36:00 +09:00
co-authored by Claude Opus 5
parent 146d808aac
commit de46f70035
7 changed files with 950 additions and 29 deletions
@@ -0,0 +1,221 @@
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Controls;
/// <summary>
/// SQL 색칠 레이어 — 투명한 <see cref="TextBox"/> 뒤에 같은 글자를 색을 입혀 그린다.
///
/// <b>왜 이 방식인가.</b> 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 했다.
/// RichTextBox 로 직접 칠하는 방법도 있지만 편집할 때마다 서식을 다시 입혀야 해서
/// 실행취소 묶음이 깨지고 IME 조합 중 깜빡인다.
/// 대신 <b>편집은 평범한 TextBox 가 그대로 하고</b>(캐럿·선택·실행취소·IME·스크롤 전부 공짜),
/// 글자만 투명하게 해서 이 레이어가 뒤에서 같은 자리에 색을 입혀 그린다.
///
/// 자리를 맞추는 조건은 하나다 — <b>글꼴·크기·여백·줄바꿈 설정이 TextBox 와 완전히 같을 것.</b>
/// 그래서 폭·글꼴을 직접 읽어 쓰고, 줄바꿈 없음(NoWrap)을 전제로 한 줄씩 그린다.
/// </summary>
public sealed class SqlHighlightLayer : FrameworkElement
{
#region Member Fields
private TextBox? source;
private ScrollViewer? scroller;
#endregion
#region Properties
/// <summary>색칠 대상 — 이 TextBox 의 글자를 뒤에서 다시 그린다</summary>
public TextBox? Source
{
get => source;
set
{
if (ReferenceEquals(source, value))
{
return;
}
Detach();
source = value;
Attach();
InvalidateVisual();
}
}
#endregion
#region Methods
private void Attach()
{
if (source is null)
{
return;
}
source.TextChanged += OnSourceChanged;
source.SizeChanged += OnSourceChanged;
source.Loaded += OnSourceLoaded;
HookScroller();
}
private void Detach()
{
if (source is not null)
{
source.TextChanged -= OnSourceChanged;
source.SizeChanged -= OnSourceChanged;
source.Loaded -= OnSourceLoaded;
}
if (scroller is not null)
{
scroller.ScrollChanged -= OnScrolled;
scroller = null;
}
}
private void OnSourceLoaded(object sender, RoutedEventArgs e)
{
HookScroller();
InvalidateVisual();
}
/// <summary>
/// TextBox 내부 ScrollViewer 를 찾아 스크롤을 따라간다.
/// 이걸 놓치면 스크롤한 순간 글자와 색이 어긋나 보인다.
/// </summary>
private void HookScroller()
{
if (source is null || scroller is not null)
{
return;
}
scroller = FindScrollViewer(source);
if (scroller is not null)
{
scroller.ScrollChanged += OnScrolled;
}
}
private static ScrollViewer? FindScrollViewer(DependencyObject root)
{
if (root is ScrollViewer found)
{
return found;
}
var count = VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < count; i++)
{
if (FindScrollViewer(VisualTreeHelper.GetChild(root, i)) is { } child)
{
return child;
}
}
return null;
}
private void OnScrolled(object sender, ScrollChangedEventArgs e) => InvalidateVisual();
private void OnSourceChanged(object sender, EventArgs e) => InvalidateVisual();
/// <summary>색 하나를 테마 자원에서 — 없으면 눈에 띄는 대체색(자원 누락을 조용히 넘기지 않는다)</summary>
private Brush BrushOf(string key)
=> TryFindResource(key) as Brush ?? Brushes.Magenta;
private Brush BrushFor(SqlTokenKind kind) => kind switch
{
SqlTokenKind.Keyword => BrushOf("B.SqlKeyword"),
SqlTokenKind.Function => BrushOf("B.SqlFunction"),
SqlTokenKind.Number => BrushOf("B.SqlNumber"),
SqlTokenKind.Text => BrushOf("B.SqlText"),
SqlTokenKind.Comment => BrushOf("B.SqlComment"),
SqlTokenKind.Variable => BrushOf("B.SqlVariable"),
SqlTokenKind.Broken => BrushOf("B.Danger"),
_ => BrushOf("B.Ink"),
};
protected override void OnRender(DrawingContext dc)
{
if (source is null || source.Text.Length == 0)
{
return;
}
var text = source.Text;
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 lineIndex = 0;
var lineStart = 0;
var height = ActualHeight;
while (lineStart <= text.Length)
{
var newline = text.IndexOf('\n', lineStart);
var lineEnd = newline < 0 ? text.Length : newline;
var y = offsetY + lineIndex * lineHeight;
// 화면 밖 줄은 그리지 않는다 — 긴 쿼리에서 매 입력마다 전부 그리면 눈에 띄게 느려진다
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>
/// 한 줄을 조각별 색으로 그린다.
/// 조각 경계마다 <see cref="FormattedText"/> 를 새로 만들되, x 위치는 <b>앞부분 전체를 다시 재서</b> 잡는다 —
/// 폭을 누적하면 자간·힌팅 때문에 뒤로 갈수록 한두 픽셀씩 밀린다.
/// </summary>
private void DrawLine(DrawingContext dc, string text, IReadOnlyList<SqlToken> tokens,
int lineStart, int lineEnd, Typeface typeface, double dpi, double offsetX, double y)
{
foreach (var token in tokens)
{
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));
}
}
#endregion
}