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; /// /// 데이터소스(MDataTable) 쿼리 전용 편집기 — 구문 강조 + 편집 보조 + 치환 변수 삽입. /// /// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery. /// 토큰 접두어는 클래스 전체 이름이어야 하며 런타임이 GetType.FullName 과 완전일치로 비교한다 /// (ucLoadSheetBase.vb:7619-7631 이 형을 고정). 목록은 참조. /// /// 색칠은 가 뒤에서 하고 편집은 투명 TextBox 가 그대로 한다 — /// 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 해서 직접 만든 구성이다. /// /// 큰 다중행 편집 영역이 필요한 다른 용도(상용구 문구)에도 재사용한다 — /// 그 경우 showVariables: false 로 SQL 변수 패널을 숨기고 색칠도 끈다. /// public partial class QueryEditorWindow : Window { #region Member Fields private readonly bool isSql; /// 스키마·검증 실행 원천 — DB 가 없으면 null 이고 그 기능만 꺼진다 private readonly SheetMe.Data.Stores.OracleQueryWorkbench? workbench; /// 테이블 목록 — 처음 필요할 때 한 번만 읽는다(수천 건이라 매번 조회하면 입력이 끊긴다) private IReadOnlyList? tableItems; /// 테이블별 컬럼 캐시 private readonly Dictionary> columnCache = new(StringComparer.OrdinalIgnoreCase); /// 자동완성이 바꿔 넣을 구간 시작 — 목록에서 고를 때 여기부터 지운다 private int completionStart; /// 자동완성이 텍스트를 고치는 중 — TextChanged 재진입을 막는다 private bool applyingCompletion; /// /// 방금 글자를 쳐서 목록이 갱신됐는지 — 커서만 움직인 경우와 구분한다. /// 타이핑이면 목록을 다시 계산하고, 그냥 커서를 옮긴 것이면 닫는다. /// private bool typing; /// 목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다 private int completionEnd; /// 이 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보 private readonly IReadOnlyList controlNames; /// 고른 갈래 칩 — 빈 문자열이면 전체 private string activeBucket = string.Empty; #endregion #region Properties - Commands /// 목록 행의 + 버튼 — 더블클릭을 모르는 사람도 넣을 수 있어야 한다 public M.Framework.WPF.ICustomCommand InsertCommand { get; } /// 검증 실행 — Ctrl+Enter / F5 단축키가 이것을 부른다 public M.Framework.WPF.ICustomCommand TrialCommand { get; } #endregion #region Properties /// 편집 결과 텍스트 — 확인 시 채워짐 public string QueryText { get; private set; } = string.Empty; #endregion #region Constructors /// 제목에 붙일 대상 이름 /// 초기 텍스트 /// SQL 치환 변수 패널 표시 여부 — 상용구 등 SQL 이 아닌 용도에서는 false /// /// 같은 서식의 컨트롤 이름 — 《컨트롤명》 치환 후보가 된다. /// 이 목록은 문서마다 다르므로 카탈로그가 아니라 호출부가 준다. /// public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true, IReadOnlyList? controlNames = null) { InitializeComponent(); isSql = showVariables; this.controlNames = controlNames ?? Array.Empty(); InsertCommand = new M.Framework.WPF.Command((object parameter) => { if (parameter is QueryVariable variable) { InsertVariable(variable); } }); TrialCommand = new M.Framework.WPF.Command((sender, e) => { if (!isSql) { return; } // 자동완성 목록이 떠 있으면 닫고 실행한다 — Enter 의 뜻(목록 확정)과 겹치지 않게 CompletionPopup.IsOpen = false; OnTrial(this, new RoutedEventArgs()); }); 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); if (!workbench.CanUseDb) { TrialButton.IsEnabled = false; TrialButton.ToolTip = "DB 접속이 설정되지 않아 검증 실행을 쓸 수 없습니다."; } ConnText.Text = workbench.CanUseDb ? "연결 상태: 정상" : "연결 상태: 없음(오프라인)"; ConnDot.Fill = (System.Windows.Media.Brush)FindResource(workbench.CanUseDb ? "B.Ok" : "B.Muted"); } else { // SQL 이 아닌 텍스트에 SQL 색을 칠하면 예약어와 겹치는 낱말이 엉뚱하게 물든다 Title = ownerLabel; TitleTextBlock.Text = ownerLabel; TargetChip.Visibility = Visibility.Collapsed; SqlBox.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink"); VariablePane.Visibility = Visibility.Collapsed; SplitterColumn.Width = new GridLength(0); VariableColumn.Width = new GridLength(0); TrialButton.Visibility = Visibility.Collapsed; ConnText.Text = "텍스트 편집"; } // 편집기가 스크롤되면 목록이 걸린 자리가 화면에서 움직인다 — 따라가지 않고 닫는다 SqlBox.AddHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler((_, _) => CompletionPopup.IsOpen = false)); // 창을 벗어나면 목록이 다른 창 위에 떠 있게 된다 Deactivated += (_, _) => CompletionPopup.IsOpen = false; Loaded += (_, _) => { SqlBox.Focus(); SqlBox.CaretIndex = SqlBox.Text.Length; Refresh(); }; } #endregion #region Methods - Completion /// /// 진단(--query-popup)에서 목록 갱신을 부르는 통로. /// 프로그램으로 Text 를 넣으면 TextChanged 시점의 커서가 아직 옛 자리라 /// 사용자가 친 것과 같은 상태를 만들 수 없다. /// internal void RefreshCompletionForSmoke() => UpdateCompletion(); /// 진단(--query-popup)에서 검증 실행을 부르는 통로 — 결과 표가 실제로 차는지 본다 internal void RunTrialForSmoke() => OnTrial(this, new RoutedEventArgs()); /// 커서 위치를 보고 목록을 띄우거나 닫는다 private void UpdateCompletion() { if (!isSql || applyingCompletion) { return; } var query = SqlCompletion.Analyze(SqlBox.Text, SqlBox.CaretIndex); if (query is null) { CompletionPopup.IsOpen = false; return; } var items = SqlCompletion.Filter(CandidatesFor(query), query.Prefix); if (items.Count == 0) { CompletionPopup.IsOpen = false; return; } // 팝업은 '완성 중인 낱말의 시작'에 붙인다 — 커서를 따라다니면 글자마다 흔들린다. // WPF Popup 은 열려 있는 동안 Placement 를 다시 계산하지 않으므로, // 기준점이 바뀐 경우에만 닫았다 다시 연다. var anchorChanged = completionStart != query.ReplaceStart; completionStart = query.ReplaceStart; completionEnd = SqlBox.CaretIndex; CompletionList.ItemsSource = items; CompletionList.SelectedIndex = 0; if (anchorChanged && CompletionPopup.IsOpen) { CompletionPopup.IsOpen = false; } if (!CompletionPopup.IsOpen) { PlaceCompletionPopup(); CompletionPopup.IsOpen = true; } } /// /// 커서가 움직였다 — 타이핑이면 목록을 다시 계산하고, 그냥 옮긴 것이면 닫는다. /// /// 이걸 안 하면 옛 문맥의 목록이 그대로 남는다. 실제로 /// FROM 에서 테이블 목록을 띄운 뒤 다음 줄로 내려가도 그 목록이 계속 떠 있었다. /// private void OnSqlSelectionChanged(object sender, RoutedEventArgs e) { if (applyingCompletion || typing) { return; } if (!CompletionPopup.IsOpen) { return; } var caret = SqlBox.CaretIndex; // 목록이 걸린 낱말 안에 있으면 유지, 벗어났으면 닫는다 if (caret < completionStart || caret > completionEnd || SqlBox.SelectionLength > 0) { CompletionPopup.IsOpen = false; } } private void OnSqlLostFocus(object sender, RoutedEventArgs e) => CompletionPopup.IsOpen = false; private IEnumerable CandidatesFor(SqlCompletionQuery query) { switch (query.Context) { case SqlCompletionContext.Variable: // 토큰에서 << >> 를 뗀 알맹이를 후보로 준다 — 커서 앞의 << 는 이미 쳐 놓았다 return LegacyQueryVariableCatalog.All.Select(v => new SqlCompletionItem(v.Token[2..^2], SqlCompletionKind.Variable, v.Description)); case SqlCompletionContext.TableName: return Tables(); case SqlCompletionContext.ColumnOf: var table = SqlCompletion.ResolveTable(SqlBox.Text, query.Qualifier); return Columns(table); default: // 이 쿼리가 참조하는 테이블의 컬럼을 먼저 준다 — 별칭을 안 쓰는 쪽이 오히려 흔하고, // 점을 찍어야만 컬럼이 뜨면 정작 필요한 자리(WHERE 절)에서 아무것도 안 나온다. var referenced = SqlCompletion.ReferencedTables(SqlBox.Text) .SelectMany(Columns) .ToList(); return referenced.Concat(SqlCompletion.BuiltIns()).Concat(Tables()); } } private IReadOnlyList Tables() { if (tableItems is not null) { return tableItems; } tableItems = workbench is null ? (IReadOnlyList)Array.Empty() : workbench.ListTables() .Select(t => new SqlCompletionItem(t.Name, SqlCompletionKind.Table, t.Kind)) .ToList(); return tableItems; } private IReadOnlyList Columns(string table) { if (columnCache.TryGetValue(table, out var cached)) { return cached; } var items = workbench is null ? (IReadOnlyList)Array.Empty() : workbench.ListColumns(table) .Select(c => new SqlCompletionItem(c, SqlCompletionKind.Column, table)) .ToList(); columnCache[table] = items; return items; } /// /// 완성 중인 낱말 바로 아래에 목록을 놓는다. /// /// GetRectFromCharacterIndex 는 스크롤·범위를 벗어나면 Empty 를 준다 — /// 그대로 쓰면 좌표가 무한대가 되어 팝업이 화면 구석으로 날아간다. 그 경우 캐럿으로 물러선다. /// private void PlaceCompletionPopup() { var index = Math.Clamp(completionStart, 0, SqlBox.Text.Length); var rect = SqlBox.GetRectFromCharacterIndex(index); if (rect.IsEmpty || double.IsInfinity(rect.Bottom) || double.IsNaN(rect.Bottom)) { rect = SqlBox.GetRectFromCharacterIndex(SqlBox.CaretIndex); } if (rect.IsEmpty || double.IsInfinity(rect.Bottom) || double.IsNaN(rect.Bottom)) { rect = new Rect(0, 0, 0, SqlBox.FontSize * 1.4); } // 편집 영역 밖으로는 내보내지 않는다. 스크롤 위치에 따라 rect 가 영역을 크게 벗어날 수 있는데, // 그대로 쓰면 목록이 창 밖(또는 화면 구석)에 뜬다 — 실제로 그렇게 보였다. var maxX = Math.Max(0, SqlBox.ActualWidth - 40); var maxY = Math.Max(0, SqlBox.ActualHeight); CompletionPopup.PlacementTarget = SqlBox; CompletionPopup.Placement = System.Windows.Controls.Primitives.PlacementMode.Relative; CompletionPopup.HorizontalOffset = Math.Clamp(rect.X, 0, maxX); CompletionPopup.VerticalOffset = Math.Clamp(rect.Bottom + 2, 0, maxY); } /// 고른 후보를 커서 앞 낱말과 바꿔 넣는다 private void AcceptCompletion() { if (CompletionList.SelectedItem is not SqlCompletionItem item) { return; } applyingCompletion = true; try { var caret = SqlBox.CaretIndex; var text = SqlBox.Text; var replaceLength = Math.Max(0, caret - completionStart); var inserted = item.Text; // 변수는 << 를 이미 쳤으므로 알맹이만 넣고 닫는 >> 를 붙여 준다 if (item.Kind == SqlCompletionKind.Variable) { completionStart += 2; // '<<' 는 남긴다 replaceLength = Math.Max(0, caret - completionStart); var alreadyClosed = text.IndexOf(">>", caret, StringComparison.Ordinal) == caret; if (!alreadyClosed) { inserted += ">>"; } } SqlBox.Text = text.Remove(completionStart, replaceLength).Insert(completionStart, inserted); SqlBox.CaretIndex = completionStart + inserted.Length; } finally { applyingCompletion = false; } CompletionPopup.IsOpen = false; Refresh(); } private void OnCompletionPicked(object sender, MouseButtonEventArgs e) => AcceptCompletion(); /// 목록이 떠 있는 동안의 키 처리 — 처리했으면 true private bool HandleCompletionKey(KeyEventArgs e) { if (!CompletionPopup.IsOpen) { return false; } switch (e.Key) { case Key.Escape: CompletionPopup.IsOpen = false; return true; case Key.Tab: case Key.Return: AcceptCompletion(); return true; case Key.Down: CompletionList.SelectedIndex = Math.Min(CompletionList.SelectedIndex + 1, CompletionList.Items.Count - 1); CompletionList.ScrollIntoView(CompletionList.SelectedItem); return true; case Key.Up: CompletionList.SelectedIndex = Math.Max(CompletionList.SelectedIndex - 1, 0); CompletionList.ScrollIntoView(CompletionList.SelectedItem); return true; default: return false; } } #endregion #region Methods - Trial /// /// 검증 실행 — 치환 변수를 자리표시로 바꾼 뒤 실제로 돌려 본다. /// /// 디자인 시점에는 환자·서식 문맥이 없어 진짜 값을 넣을 수 없다. /// 목적은 데이터 확인이 아니라 테이블·컬럼 오타와 구문 오류를 배포 전에 잡는 것이다. /// private void OnTrial(object sender, RoutedEventArgs e) { if (workbench is null) { return; } var probe = SqlBox.Text; foreach (var token in SqlTokenizer.VariablesIn(probe).Distinct(StringComparer.Ordinal)) { // 런타임도 값이 없으면 빈 문자열로 치환한다(clsMDataTable.vb:67-71) — 같은 모양으로 맞춘다 probe = probe.Replace(token, string.Empty, StringComparison.Ordinal); } Mouse.OverrideCursor = Cursors.Wait; SheetMe.Data.Stores.QueryTrialResult result; try { result = workbench.Trial(probe); } finally { Mouse.OverrideCursor = null; } TrialPane.Visibility = Visibility.Visible; if (result.Ok) { TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink"); // 행이 0인 것과 실패는 다르다 — 구문은 맞는데 조건에 걸리는 데이터가 없을 뿐이다. // 치환 변수를 빈 값으로 바꿔 실행하므로 조건이 빡빡하면 0행이 정상이다. TrialSummary.Text = result.Rows.Count == 0 ? result.Summary + " — 구문은 정상입니다. 치환 변수를 빈 값으로 실행하므로 조건에 걸리는 행이 없을 수 있습니다." : result.Summary; TrialGrid.ItemsSource = ToGridRows(result); TrialGrid.Visibility = result.Rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed; } else { TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Danger"); TrialSummary.Text = "실패 — " + result.Error; TrialGrid.ItemsSource = null; TrialGrid.Visibility = Visibility.Collapsed; } } /// /// 결과를 DataGrid 가 열까지 만들어 주는 형태로 옮긴다. /// /// ExpandoObject 로는 안 된다. DataGrid 의 AutoGenerateColumns 는 항목 타입의 /// 속성을 반사해 열을 만드는데 ExpandoObject 에는 그런 속성이 없다 — /// 그래서 요약만 뜨고 표는 비어 보였다. DataTable 은 DataGrid 가 직접 알아본다. /// private static System.Data.DataView ToGridRows(SheetMe.Data.Stores.QueryTrialResult result) { var table = new System.Data.DataTable(); for (var i = 0; i < result.Columns.Count; i++) { // 같은 이름이 두 번 나오면 DataTable 이 예외를 낸다(별칭 없는 조인에서 흔하다) var name = result.Columns[i]; if (table.Columns.Contains(name)) { name = $"{name}_{i}"; } table.Columns.Add(name, typeof(string)); } foreach (var values in result.Rows) { var row = table.NewRow(); for (var i = 0; i < table.Columns.Count && i < values.Length; i++) { row[i] = values[i]; } table.Rows.Add(row); } return table.DefaultView; } private void OnCloseTrial(object sender, RoutedEventArgs e) => TrialPane.Visibility = Visibility.Collapsed; #endregion #region Methods - Editing private void OnSqlChanged(object sender, TextChangedEventArgs e) { Refresh(); // TextChanged 다음에 SelectionChanged 가 이어서 오는데, 그건 타이핑에 따른 커서 이동이라 // '커서만 옮겼다'로 오인해 목록을 닫으면 안 된다. typing = true; try { UpdateCompletion(); } finally { Dispatcher.BeginInvoke(new Action(() => typing = false), System.Windows.Threading.DispatcherPriority.Input); } } private void Refresh() { var text = SqlBox.Text; LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄"; UpdateCaretText(); UpdateLineNumbers(text); UpdateProblems(text); } /// 줄·열 표시 — 오류 메시지의 위치를 찾아갈 때 쓴다 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}"; } /// 포맷 — 예약어 대문자, 절마다 줄바꿈. 문자열·치환 변수는 건드리지 않는다 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(); } /// 전체 지우기 — 되돌릴 수 있게 TextBox 실행취소에 남긴다 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; 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(); } /// /// 저장 전에 눈에 띄어야 하는 것만 알린다 — 닫히지 않은 조각과 축약된 치환 변수. /// /// 런타임은 치환에 실패해도 빈 catch 로 삼켜 화면에 아무 표시가 없다. /// 여기서 잡지 못하면 배포된 뒤 임상 화면에서 빈칸으로만 드러난다. /// private void UpdateProblems(string text) { if (!isSql) { ProblemText.Text = string.Empty; return; } var problems = new List(); if (SqlTokenizer.Tokenize(text).Any(t => t.Kind == SqlTokenKind.Broken)) { problems.Add("닫히지 않은 따옴표 또는 << >>"); } // 목록에 없다고 전부 틀린 것은 아니다 — DataRow 컬럼은 무한히 많다. // 런타임이 실제로 해석할 수 있는 '형태'인지로 판정한다(접두어 + clsMDataTable 정규식). var bad = SqlTokenizer.VariablesIn(text) .Where(v => !LegacyQueryVariableCatalog.IsResolvable(v)) .Distinct(StringComparer.Ordinal) .ToList(); if (bad.Count > 0) { problems.Add($"치환되지 않는 변수 {bad.Count}건: {bad[0]}"); } // 컬럼명을 안 채운 채 저장하면 그 칸은 런타임에서 조용히 빈값이 된다 if (text.Contains("컬럼명\"", StringComparison.Ordinal)) { problems.Add("컬럼명 자리를 채우세요"); } ProblemText.Text = problems.Count == 0 ? string.Empty : string.Join(" · ", problems); } /// /// 편집 보조 — Tab 들여쓰기, Enter 자동 들여쓰기, Ctrl+/ 주석 토글. /// TextBox 가 기본으로 주지 않는 것 중 SQL 을 칠 때 손이 가장 많이 가는 것들만 넣었다. /// private void OnSqlKeyDown(object sender, KeyEventArgs e) { var ctrl = (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control; var shift = (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift; // 목록이 떠 있으면 방향키·Enter·Tab 은 목록 것이다. // 단 Ctrl+Enter 는 검증 실행이라 목록이 가로채면 안 된다 — 그건 KeyBinding 이 받는다. if (!(ctrl && e.Key is Key.Return or Key.Enter) && HandleCompletionKey(e)) { e.Handled = true; return; } // Ctrl+Space — 목록을 손으로 부른다 if (ctrl && e.Key == Key.Space) { UpdateCompletion(); e.Handled = true; return; } if (AutoClosePair(e.Key, shift)) { e.Handled = true; return; } 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; } } /// /// 따옴표·괄호 자동 닫기 — 선택이 있으면 그 선택을 감싼다. /// /// SQL 에서 '…'(…) 는 짝을 빠뜨리기 쉽고, 빠뜨리면 런타임에서 조용히 실패한다. /// 여는 쪽을 칠 때 닫는 쪽을 같이 넣고 커서를 사이에 둔다. /// private bool AutoClosePair(Key key, bool shift) { // 작은따옴표(Shift 없는 OemQuotes) 와 여는 괄호(Shift+9) var pair = (key, shift) switch { (Key.OemQuotes, false) => ("'", "'"), (Key.D9, true) => ("(", ")"), _ => (string.Empty, string.Empty), }; if (pair.Item1.Length == 0) { return false; } var start = SqlBox.SelectionStart; var length = SqlBox.SelectionLength; var inner = length > 0 ? SqlBox.Text.Substring(start, length) : string.Empty; var insert = pair.Item1 + inner + pair.Item2; SqlBox.Text = SqlBox.Text.Remove(start, length).Insert(start, insert); // 선택을 감쌌으면 감싼 내용을 다시 선택하고, 아니면 따옴표 사이에 커서를 둔다 if (length > 0) { SqlBox.Select(start + 1, inner.Length); } else { SqlBox.CaretIndex = start + 1; } return true; } /// 현재 줄의 시작 위치 private int LineStartOf(int index) { var text = SqlBox.Text; var start = index; while (start > 0 && text[start - 1] != '\n') { start--; } return start; } /// Enter — 앞 줄의 들여쓰기를 그대로 물려준다 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; } /// Tab / Shift+Tab — 선택한 줄 전체를 들여쓰거나 내어쓴다 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; } /// Ctrl+/ — 선택한 줄에 -- 를 붙이거나 뗀다 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) { SearchPlaceholder.Visibility = VariableSearch.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed; ApplyVariableFilter(VariableSearch.Text); } /// 카탈로그 + 이 서식의 컨트롤 — 컨트롤은 문서마다 달라 여기서 합친다 private IReadOnlyList AllVariables() => LegacyQueryVariableCatalog.All .Concat(LegacyQueryVariableCatalog.ControlVariables(controlNames)) .ToList(); /// 상단 칩을 다시 만든다 — 갈래별 개수는 검색과 무관하게 전체 기준으로 둔다 private void RebuildBucketChips() { var all = AllVariables(); var chips = new List { new { Name = "전체", Count = all.Count, IsActive = activeBucket.Length == 0 }, }; // 순서는 개수가 아니라 고정이다 — 컨트롤 수는 서식마다 달라서, 개수순으로 두면 // 서식을 바꿀 때마다 칩이 자리를 바꿔 손이 기억하지 못한다. var order = new[] { "환자", "서식", "작업자", "컨트롤" }; var byBucket = all.GroupBy(v => v.Bucket).ToDictionary(g => g.Key, g => g.Count()); foreach (var name in order) { if (byBucket.TryGetValue(name, out var count) && count > 0) { chips.Add(new { Name = name, Count = count, IsActive = activeBucket == name }); } } 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); } /// 이름·설명·초성으로 좁히고 갈래 칩을 함께 적용한다 private void ApplyVariableFilter(string keyword) { var all = AllVariables(); var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); 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; 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 QueryVariable variable) { InsertVariable(variable); } } private void InsertVariable(QueryVariable variable) { var caret = SqlBox.CaretIndex; SqlBox.Text = SqlBox.Text.Insert(caret, variable.Token); SqlBox.Focus(); // DataRow 형태는 컬럼명을 채워야 완성된다 — 그 자리를 선택해 두면 바로 덮어쓸 수 있다 const string Placeholder = "컬럼명"; var placeholderAt = variable.NeedsColumn ? variable.Token.IndexOf(Placeholder, StringComparison.Ordinal) : -1; if (placeholderAt >= 0) { SqlBox.Select(caret + placeholderAt, Placeholder.Length); } else { SqlBox.CaretIndex = caret + variable.Token.Length; } } #endregion #region Methods - Confirm private void OnConfirm(object sender, RoutedEventArgs e) { QueryText = SqlBox.Text; DialogResult = true; } #endregion }