쿼리 편집기 레이아웃 — 카드 두 장, 갈래 칩, 그리고 《컨트롤명》 치환

목업대로 다시 짰다. 색·모서리·간격은 SheetMe 토큰을 그대로 쓴다.

■ 레이아웃

머리말(제목 + 대상 칩) / SQL 편집기 카드 / 치환 변수 카드 / 아래 버튼 줄.
편집기 카드에는 툴바(포맷·주석·자동완성 · 전체 지우기)와 상태 줄(연결 상태 · 글자·줄 수 · 줄,열)을 뒀다.
변수 카드에는 검색 + 갈래 칩 + 접이식 그룹 + 행마다 + 버튼을 뒀다.

+ 버튼을 넣은 이유는 더블클릭이 유일한 삽입 수단이었기 때문이다 — 화면에 드러나지 않는 조작이다.

■ 《컨트롤명》 치환 — 아예 없던 기능

레거시 화면에는 Controls 목록이 따로 있었다. 우리에겐 그 문법 자체가 없었다.
clsMDataTable.ConvertQuery 는 <<…>> 를 모두 치환한 뒤 《 로 다시 쪼개
poAllControlOnSheet(이름).GETVALUE 를 박는다(:78-94). 즉 같은 서식의 다른 컨트롤 값을
쿼리 조건에 넣을 수 있다.

이제 이 서식의 컨트롤이 '컨트롤' 갈래로 목록에 뜨고, 토크나이저도 《》 를 변수로 잡아
색이 붙고 검증에 걸린다. 다만 디자인 시점에는 컨트롤 목록이 없어 런타임이 무조건 "0" 으로
치환한다(:86) — 검증 실행이 통과해도 운영에서는 다른 값이 들어간다는 뜻이라 코드에 적어 뒀다.

■ 형식(String/Int32/DataRow) 표시

레거시가 '목록/형식' 두 열로 보여 주던 정보다. DataRow 인지 아닌지가 곧
'컬럼명을 더 채워야 하는가'를 뜻해서 고를 때 필요하다.

■ 갈래 칩

그룹이 일곱이라 칩으로 다 늘어놓으면 그것대로 목록이 된다.
'값이 어디서 오는가'로 네 갈래(환자·서식·컨트롤·작업자)로 묶고, 같은 칩을 다시 누르면 해제된다.

■ 포맷(Ctrl+Shift+F 상당 — 툴바 버튼)

예약어·함수를 대문자로 바꾸고 주요 절 앞에서 줄을 나누며 괄호 깊이만큼 들여쓴다.
문자열·주석·치환 변수는 손대지 않는다 — 특히 치환 변수는 대소문자가 맞아야 런타임이 찾으므로
대문자로 바꾸면 그 칸이 조용히 빈다. 이 포매터가 저지를 수 있는 가장 나쁜 일이라 테스트로 막았다.
두 번 눌러도 결과가 같다(안정성도 테스트로 고정).

단위 테스트 14건 추가(포매터 11 · 컨트롤 치환 3).
회귀: 테스트 230/230, 편집 스모크 실패 0, 팝업 점검 7/7, 검증 실행 점검 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 15:53:56 +09:00
co-authored by Claude Opus 5
parent 7ce7506286
commit 68fc01c6ba
13 changed files with 847 additions and 168 deletions
@@ -49,6 +49,17 @@ public partial class QueryEditorWindow : Window
/// <summary>목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다</summary>
private int completionEnd;
/// <summary>이 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보</summary>
private readonly IReadOnlyList<string> controlNames;
/// <summary>고른 갈래 칩 — 빈 문자열이면 전체</summary>
private string activeBucket = string.Empty;
#endregion
#region Properties - Commands
/// <summary>목록 행의 + 버튼 — 더블클릭을 모르는 사람도 넣을 수 있어야 한다</summary>
public M.Framework.WPF.ICustomCommand InsertCommand { get; }
#endregion
#region Properties
@@ -60,16 +71,32 @@ public partial class QueryEditorWindow : Window
/// <param name="ownerLabel">제목에 붙일 대상 이름</param>
/// <param name="initialQuery">초기 텍스트</param>
/// <param name="showVariables">SQL 치환 변수 패널 표시 여부 — 상용구 등 SQL 이 아닌 용도에서는 false</param>
public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true)
/// <param name="controlNames">
/// 같은 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보가 된다.
/// 이 목록은 문서마다 다르므로 카탈로그가 아니라 호출부가 준다.
/// </param>
public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true,
IReadOnlyList<string>? controlNames = null)
{
InitializeComponent();
isSql = showVariables;
this.controlNames = controlNames ?? Array.Empty<string>();
InsertCommand = new M.Framework.WPF.Command((object parameter) =>
{
if (parameter is QueryVariable variable)
{
InsertVariable(variable);
}
});
DataContext = this;
SqlBox.Text = initialQuery;
TargetChipText.Text = ownerLabel;
if (showVariables)
{
Title = $"쿼리 편집 — {ownerLabel}";
Highlight.Source = SqlBox;
RebuildBucketChips();
ApplyVariableFilter(string.Empty);
var connection = Services.ConfigService.Current.ConnectionString;
workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(connection);
@@ -78,6 +105,8 @@ public partial class QueryEditorWindow : Window
TrialButton.IsEnabled = false;
TrialButton.ToolTip = "DB 접속이 설정되지 않아 검증 실행을 쓸 수 없습니다.";
}
ConnText.Text = workbench.CanUseDb ? "연결 상태: 정상" : "연결 상태: 없음(오프라인)";
ConnDot.Fill = (System.Windows.Media.Brush)FindResource(workbench.CanUseDb ? "B.Ok" : "B.Muted");
}
else
{
@@ -88,6 +117,8 @@ public partial class QueryEditorWindow : Window
VariablePane.Visibility = Visibility.Collapsed;
SplitterColumn.Width = new GridLength(0);
VariableColumn.Width = new GridLength(0);
TrialButton.Visibility = Visibility.Collapsed;
ConnText.Text = "텍스트 편집";
}
// 편집기가 스크롤되면 목록이 걸린 자리가 화면에서 움직인다 — 따라가지 않고 닫는다
@@ -429,10 +460,59 @@ public partial class QueryEditorWindow : Window
{
var text = SqlBox.Text;
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
UpdateCaretText();
UpdateLineNumbers(text);
UpdateProblems(text);
}
/// <summary>줄·열 표시 — 오류 메시지의 위치를 찾아갈 때 쓴다</summary>
private void UpdateCaretText()
{
var caret = Math.Clamp(SqlBox.CaretIndex, 0, SqlBox.Text.Length);
var line = SqlBox.GetLineIndexFromCharacterIndex(caret);
var column = line >= 0 ? caret - SqlBox.GetCharacterIndexFromLineIndex(line) : 0;
CaretText.Text = $"줄 {Math.Max(0, line) + 1}, 열 {Math.Max(0, column) + 1}";
}
/// <summary>포맷 — 예약어 대문자, 절마다 줄바꿈. 문자열·치환 변수는 건드리지 않는다</summary>
private void OnFormat(object sender, RoutedEventArgs e)
{
if (!isSql)
{
return;
}
var formatted = SqlFormatter.Format(SqlBox.Text);
if (!string.Equals(formatted, SqlBox.Text, StringComparison.Ordinal))
{
SqlBox.Text = formatted;
SqlBox.CaretIndex = SqlBox.Text.Length;
}
}
private void OnToggleComment(object sender, RoutedEventArgs e)
{
SqlBox.Focus();
ToggleComment();
}
private void OnInvokeCompletion(object sender, RoutedEventArgs e)
{
SqlBox.Focus();
UpdateCompletion();
}
/// <summary>전체 지우기 — 되돌릴 수 있게 TextBox 실행취소에 남긴다</summary>
private void OnClearAll(object sender, RoutedEventArgs e)
{
if (SqlBox.Text.Length == 0)
{
return;
}
SqlBox.SelectAll();
SqlBox.SelectedText = string.Empty;
SqlBox.Focus();
}
private static int CountLines(string text)
{
var lines = 1;
@@ -714,23 +794,57 @@ public partial class QueryEditorWindow : Window
private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
=> ApplyVariableFilter(VariableSearch.Text);
/// <summary>이름·설명·초성으로 좁힌다 — 목록이 길어 눈으로 훑기 어렵다</summary>
/// <summary>카탈로그 + 이 서식의 컨트롤 — 컨트롤은 문서마다 달라 여기서 합친다</summary>
private IReadOnlyList<QueryVariable> AllVariables()
=> LegacyQueryVariableCatalog.All
.Concat(LegacyQueryVariableCatalog.ControlVariables(controlNames))
.ToList();
/// <summary>상단 칩을 다시 만든다 — 갈래별 개수는 검색과 무관하게 전체 기준으로 둔다</summary>
private void RebuildBucketChips()
{
var all = AllVariables();
var chips = new List<object>
{
new { Name = "전체", Count = all.Count, IsActive = activeBucket.Length == 0 },
};
foreach (var group in all.GroupBy(v => v.Bucket).OrderByDescending(g => g.Count()))
{
chips.Add(new { Name = group.Key, Count = group.Count(), IsActive = activeBucket == group.Key });
}
BucketChips.ItemsSource = chips;
}
private void OnBucketClick(object sender, RoutedEventArgs e)
{
if (sender is not System.Windows.Controls.Primitives.ToggleButton { Tag: string name })
{
return;
}
// 같은 칩을 다시 누르면 해제 — '전체'와 같은 뜻이라 따로 누를 필요가 없다
activeBucket = name == "전체" || activeBucket == name ? string.Empty : name;
RebuildBucketChips();
ApplyVariableFilter(VariableSearch.Text);
}
/// <summary>이름·설명·초성으로 좁히고 갈래 칩을 함께 적용한다</summary>
private void ApplyVariableFilter(string keyword)
{
var all = AllVariables();
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 matched = all
.Where(v => activeBucket.Length == 0 || v.Bucket == activeBucket)
.Where(v => tokens.Length == 0 || 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 로 삽입니다.";
var scope = matched.Count == all.Count ? $"{matched.Count}개" : $"{matched.Count}/{all.Count}개";
VariableHint.Text = $"{scope} · + 를 누르거나 더블클릭하면 커서 위치에 삽입니다. " +
"편집기에서 << 를 치면 자동완성으로도 고를 수 있습니다.";
if (matched.Count > 0)
{
VariableList.SelectedIndex = 0;
@@ -750,10 +864,14 @@ public partial class QueryEditorWindow : Window
private void InsertSelectedVariable()
{
if (VariableList.SelectedItem is not QueryVariable variable)
if (VariableList.SelectedItem is QueryVariable variable)
{
return;
InsertVariable(variable);
}
}
private void InsertVariable(QueryVariable variable)
{
var caret = SqlBox.CaretIndex;
SqlBox.Text = SqlBox.Text.Insert(caret, variable.Token);
SqlBox.Focus();