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

배포본에 서드파티 편집기 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
@@ -1,16 +1,20 @@
<Window x:Class="SheetMe.Designer.Views.QueryEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="쿼리 편집" Width="960" Height="560" MinWidth="720" MinHeight="400"
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="쿼리 편집" Width="1060" Height="640" MinWidth="780" MinHeight="440"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
<DockPanel Margin="12">
<TextBlock x:Name="HintText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="작성 시점에 &lt;&lt;변수&gt;&gt; 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록을 더블클릭해 삽입하세요 — 임의로 줄여 쓰면 치환되지 않고 SQL 에 그대로 남아 오류가 납니다."/>
Text="작성 시점에 &lt;&lt;변수&gt;&gt; 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록에서 삽입하세요 — 임의로 줄여 쓰면 치환되지 않고 SQL 에 그대로 남아 오류가 납니다."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
<Button Content="확인" Padding="20,5" Click="OnConfirm"/>
<TextBlock x:Name="ProblemText" VerticalAlignment="Center" Margin="0,0,12,0"
Foreground="{DynamicResource B.Danger}" FontSize="11.5"
TextTrimming="CharacterEllipsis" MaxWidth="420"/>
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}" FontSize="11.5"/>
<Button Content="확인" Padding="20,5" Click="OnConfirm" IsDefault="True"/>
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
@@ -18,32 +22,70 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition x:Name="SplitterColumn" Width="8"/>
<ColumnDefinition x:Name="VariableColumn" Width="330"/>
<ColumnDefinition x:Name="VariableColumn" Width="360"/>
</Grid.ColumnDefinitions>
<!-- SQL 편집 영역 -->
<TextBox x:Name="SqlBox" Grid.Column="0"
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
AcceptsReturn="True" AcceptsTab="True"
TextWrapping="NoWrap"
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
TextChanged="OnSqlChanged"/>
<!-- SQL 편집 영역.
색칠은 뒤에 깔린 SqlHighlightLayer 가 하고, 편집은 위에 얹힌 투명 TextBox 가 그대로 한다
(캐럿·선택·실행취소·IME 를 직접 만들지 않기 위해서다).
두 요소의 글꼴·크기·여백이 어긋나면 글자와 색이 어긋난다 — 반드시 같이 고칠 것. -->
<Border Grid.Column="0" BorderThickness="1" CornerRadius="6" ClipToBounds="True"
BorderBrush="{DynamicResource B.InputBorder}" Background="{DynamicResource B.Input}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 공용 ColSplitter 스타일을 쓴다 — Background 만 주면 양 테마에서 배경과 1.1~1.3:1 로 붙어 손잡이가 안 보인다 -->
<GridSplitter Grid.Column="1" Style="{StaticResource ColSplitter}"/>
<!-- 줄 번호 -->
<Border Grid.Column="0" Background="{DynamicResource B.Surface}" Padding="6,6,6,6">
<TextBlock x:Name="LineNumbers" FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
Foreground="{DynamicResource B.Muted}" TextAlignment="Right" MinWidth="26"/>
</Border>
<!-- 치환 변수 목록 (SQL 용도에서만 표시) -->
<Grid Grid.Column="1">
<ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/>
<TextBox x:Name="SqlBox"
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
AcceptsReturn="True" AcceptsTab="False"
TextWrapping="NoWrap" Padding="6"
Background="Transparent" BorderThickness="0"
Foreground="Transparent"
CaretBrush="{DynamicResource B.Ink}"
SelectionBrush="{DynamicResource B.Accent}" SelectionOpacity="0.35"
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
TextChanged="OnSqlChanged" PreviewKeyDown="OnSqlKeyDown"
AutomationProperties.Name="SQL 편집"/>
</Grid>
</Grid>
</Border>
<GridSplitter Grid.Column="1" Style="{StaticResource ColSplitter}"/>
<!-- 치환 변수 (SQL 용도에서만) -->
<DockPanel x:Name="VariablePane" Grid.Column="2">
<TextBlock DockPanel.Dock="Top" Text="치환 변수 (더블클릭 삽입)" FontWeight="Bold"
<TextBlock DockPanel.Dock="Top" Text="치환 변수" FontWeight="Bold"
Foreground="{DynamicResource B.Muted}" Margin="4,0,0,6"/>
<TextBox x:Name="VariableSearch" DockPanel.Dock="Top" Margin="0,0,0,6" Padding="6,3"
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
AutomationProperties.Name="치환 변수 검색"
ToolTip="이름·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
<TextBlock DockPanel.Dock="Bottom" x:Name="VariableHint" FontSize="10.5" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}" Margin="4,6,0,0"
Text="더블클릭 또는 Enter 로 커서 위치에 삽입합니다."/>
<ListBox x:Name="VariableList" FontSize="11" MouseDoubleClick="OnInsertVariable"
KeyDown="OnVariableListKeyDown"
FontFamily="Consolas, D2Coding, 굴림체"
ScrollViewer.HorizontalScrollBarVisibility="Auto">
HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
AutomationProperties.Name="치환 변수 목록">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,1" ToolTip="{Binding Token}">
<TextBlock Text="{Binding Description}" FontFamily="Segoe UI, 맑은 고딕" FontSize="12"/>
<TextBlock Text="{Binding Token}" Foreground="{DynamicResource B.Muted}" FontSize="10"/>
<TextBlock Text="{Binding Description}" FontFamily="Segoe UI, 맑은 고딕" FontSize="12"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Token}" Foreground="{DynamicResource B.Muted}" FontSize="10"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -1,22 +1,31 @@
using System.ComponentModel;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Views;
/// <summary>
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 큰 SQL 편집 영역 + 치환 변수 삽입.
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 구문 강조 + 편집 보조 + 치환 변수 삽입.
///
/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery.
/// 토큰 접두어는 <b>클래스 전체 이름</b>이어야 하며 런타임이 GetType.FullName 과 완전일치로 비교한다
/// (ucLoadSheetBase.vb:7619-7631 이 형을 고정). 목록은 <see cref="LegacyQueryVariableCatalog"/> 참조.
///
/// 색칠은 <see cref="Controls.SqlHighlightLayer"/> 가 뒤에서 하고 편집은 투명 TextBox 가 그대로 한다 —
/// 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 해서 직접 만든 구성이다.
///
/// 큰 다중행 편집 영역이 필요한 다른 용도(상용구 문구)에도 재사용한다 —
/// 그 경우 <c>showVariables: false</c> 로 SQL 변수 패널을 숨다.
/// 그 경우 <c>showVariables: false</c> 로 SQL 변수 패널을 숨기고 색칠도 끈다.
/// </summary>
public partial class QueryEditorWindow : Window
{
#region Member Fields
private readonly bool isSql;
#endregion
#region Properties
/// <summary>편집 결과 텍스트 — 확인 시 채워짐</summary>
public string QueryText { get; private set; } = string.Empty;
@@ -29,19 +38,21 @@ public partial class QueryEditorWindow : Window
public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true)
{
InitializeComponent();
isSql = showVariables;
SqlBox.Text = initialQuery;
if (showVariables)
{
Title = $"쿼리 편집 — {ownerLabel}";
var view = new CollectionViewSource { Source = LegacyQueryVariableCatalog.All };
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
VariableList.ItemsSource = view.View;
Highlight.Source = SqlBox;
ApplyVariableFilter(string.Empty);
}
else
{
// SQL 이 아닌 텍스트에 SQL 색을 칠하면 예약어와 겹치는 낱말이 엉뚱하게 물든다
Title = ownerLabel;
HintText.Text = "내용을 편집한 뒤 확인을 누르세요.";
SqlBox.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink");
VariablePane.Visibility = Visibility.Collapsed;
SplitterColumn.Width = new GridLength(0);
VariableColumn.Width = new GridLength(0);
@@ -51,15 +62,267 @@ public partial class QueryEditorWindow : Window
{
SqlBox.Focus();
SqlBox.CaretIndex = SqlBox.Text.Length;
Refresh();
};
}
#endregion
#region Methods
private void OnSqlChanged(object sender, TextChangedEventArgs e)
=> LengthText.Text = $"{SqlBox.Text.Length:N0}자";
#region Methods - Editing
private void OnSqlChanged(object sender, TextChangedEventArgs e) => Refresh();
private void OnInsertVariable(object sender, System.Windows.Input.MouseButtonEventArgs e)
private void Refresh()
{
var text = SqlBox.Text;
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
UpdateLineNumbers(text);
UpdateProblems(text);
}
private static int CountLines(string text)
{
var lines = 1;
foreach (var ch in text)
{
if (ch == '\n')
{
lines++;
}
}
return lines;
}
private void UpdateLineNumbers(string text)
{
var builder = new StringBuilder();
var count = CountLines(text);
for (var i = 1; i <= count; i++)
{
builder.Append(i).Append('\n');
}
LineNumbers.Text = builder.ToString();
}
/// <summary>
/// 저장 전에 눈에 띄어야 하는 것만 알린다 — 닫히지 않은 조각과 축약된 치환 변수.
///
/// 런타임은 치환에 실패해도 빈 catch 로 삼켜 화면에 아무 표시가 없다.
/// 여기서 잡지 못하면 배포된 뒤 임상 화면에서 빈칸으로만 드러난다.
/// </summary>
private void UpdateProblems(string text)
{
if (!isSql)
{
ProblemText.Text = string.Empty;
return;
}
var problems = new List<string>();
if (SqlTokenizer.Tokenize(text).Any(t => t.Kind == SqlTokenKind.Broken))
{
problems.Add("닫히지 않은 따옴표 또는 << >>");
}
var known = LegacyQueryVariableCatalog.All.Select(v => v.Token).ToHashSet(StringComparer.Ordinal);
var unknown = SqlTokenizer.VariablesIn(text)
.Where(v => !known.Contains(v))
.Distinct(StringComparer.Ordinal)
.ToList();
if (unknown.Count > 0)
{
problems.Add($"목록에 없는 변수 {unknown.Count}건: {unknown[0]}");
}
ProblemText.Text = problems.Count == 0 ? string.Empty : string.Join(" · ", problems);
}
/// <summary>
/// 편집 보조 — Tab 들여쓰기, Enter 자동 들여쓰기, Ctrl+/ 주석 토글.
/// TextBox 가 기본으로 주지 않는 것 중 SQL 을 칠 때 손이 가장 많이 가는 것들만 넣었다.
/// </summary>
private void OnSqlKeyDown(object sender, KeyEventArgs e)
{
var ctrl = (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control;
var shift = (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift;
if (e.Key == Key.Tab)
{
IndentSelection(outdent: shift);
e.Handled = true;
return;
}
if (ctrl && (e.Key == Key.Oem2 || e.Key == Key.Divide))
{
ToggleComment();
e.Handled = true;
return;
}
if (e.Key == Key.Return && !ctrl)
{
AutoIndentNewLine();
e.Handled = true;
}
}
/// <summary>현재 줄의 시작 위치</summary>
private int LineStartOf(int index)
{
var text = SqlBox.Text;
var start = index;
while (start > 0 && text[start - 1] != '\n')
{
start--;
}
return start;
}
/// <summary>Enter — 앞 줄의 들여쓰기를 그대로 물려준다</summary>
private void AutoIndentNewLine()
{
var text = SqlBox.Text;
var caret = SqlBox.SelectionStart;
var lineStart = LineStartOf(caret);
var indent = 0;
while (lineStart + indent < text.Length && lineStart + indent < caret
&& (text[lineStart + indent] == ' ' || text[lineStart + indent] == '\t'))
{
indent++;
}
var insert = "\n" + text.Substring(lineStart, indent);
var selectionLength = SqlBox.SelectionLength;
SqlBox.Text = text.Remove(caret, selectionLength).Insert(caret, insert);
SqlBox.CaretIndex = caret + insert.Length;
}
/// <summary>Tab / Shift+Tab — 선택한 줄 전체를 들여쓰거나 내어쓴다</summary>
private void IndentSelection(bool outdent)
{
const string Unit = " ";
var text = SqlBox.Text;
var start = SqlBox.SelectionStart;
var length = SqlBox.SelectionLength;
// 선택이 없으면 커서 자리에 공백만 넣는다(내어쓰기는 줄 단위로 동작)
if (length == 0 && !outdent)
{
SqlBox.Text = text.Insert(start, Unit);
SqlBox.CaretIndex = start + Unit.Length;
return;
}
var blockStart = LineStartOf(start);
var blockEnd = start + length;
while (blockEnd < text.Length && text[blockEnd] != '\n')
{
blockEnd++;
}
var lines = text[blockStart..blockEnd].Split('\n');
for (var i = 0; i < lines.Length; i++)
{
if (outdent)
{
if (lines[i].StartsWith(Unit, StringComparison.Ordinal))
{
lines[i] = lines[i][Unit.Length..];
}
else
{
lines[i] = lines[i].TrimStart(' ', '\t');
}
}
else
{
lines[i] = Unit + lines[i];
}
}
var replaced = string.Join('\n', lines);
SqlBox.Text = text[..blockStart] + replaced + text[blockEnd..];
SqlBox.SelectionStart = blockStart;
SqlBox.SelectionLength = replaced.Length;
}
/// <summary>Ctrl+/ — 선택한 줄에 -- 를 붙이거나 뗀다</summary>
private void ToggleComment()
{
const string Mark = "-- ";
var text = SqlBox.Text;
var start = SqlBox.SelectionStart;
var blockStart = LineStartOf(start);
var blockEnd = start + SqlBox.SelectionLength;
while (blockEnd < text.Length && text[blockEnd] != '\n')
{
blockEnd++;
}
var lines = text[blockStart..blockEnd].Split('\n');
// 한 줄이라도 주석이 아니면 전부 붙인다 — 섞인 상태에서 토글이 예측 가능해진다
var allCommented = lines.All(l => l.TrimStart().StartsWith("--", StringComparison.Ordinal));
for (var i = 0; i < lines.Length; i++)
{
if (allCommented)
{
var trimmed = lines[i].TrimStart();
var pad = lines[i][..(lines[i].Length - trimmed.Length)];
trimmed = trimmed[2..];
if (trimmed.StartsWith(' '))
{
trimmed = trimmed[1..];
}
lines[i] = pad + trimmed;
}
else
{
lines[i] = Mark + lines[i];
}
}
var replaced = string.Join('\n', lines);
SqlBox.Text = text[..blockStart] + replaced + text[blockEnd..];
SqlBox.SelectionStart = blockStart;
SqlBox.SelectionLength = replaced.Length;
}
#endregion
#region Methods - Variables
private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
=> ApplyVariableFilter(VariableSearch.Text);
/// <summary>이름·설명·초성으로 좁힌다 — 목록이 길어 눈으로 훑기 어렵다</summary>
private void ApplyVariableFilter(string keyword)
{
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var matched = tokens.Length == 0
? LegacyQueryVariableCatalog.All
: LegacyQueryVariableCatalog.All
.Where(v => tokens.All(t =>
TagSearch.Matches(v.Description, t) || TagSearch.Matches(v.Token, t)))
.ToList();
var view = new CollectionViewSource { Source = matched };
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
VariableList.ItemsSource = view.View;
VariableHint.Text = matched.Count == LegacyQueryVariableCatalog.All.Count
? $"{matched.Count}개 · 더블클릭 또는 Enter 로 삽입합니다."
: $"{matched.Count}/{LegacyQueryVariableCatalog.All.Count}개 · 더블클릭 또는 Enter 로 삽입합니다.";
if (matched.Count > 0)
{
VariableList.SelectedIndex = 0;
}
}
private void OnVariableListKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Return)
{
InsertSelectedVariable();
e.Handled = true;
}
}
private void OnInsertVariable(object sender, MouseButtonEventArgs e) => InsertSelectedVariable();
private void InsertSelectedVariable()
{
if (VariableList.SelectedItem is not QueryVariable variable)
{
@@ -70,7 +333,9 @@ public partial class QueryEditorWindow : Window
SqlBox.CaretIndex = caret + variable.Token.Length;
SqlBox.Focus();
}
#endregion
#region Methods - Confirm
private void OnConfirm(object sender, RoutedEventArgs e)
{
QueryText = SqlBox.Text;