diff --git a/src/SheetMe.Designer/Controls/LineNumberGutter.cs b/src/SheetMe.Designer/Controls/LineNumberGutter.cs
new file mode 100644
index 0000000..6ca7ab3
--- /dev/null
+++ b/src/SheetMe.Designer/Controls/LineNumberGutter.cs
@@ -0,0 +1,174 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+
+namespace SheetMe.Designer.Controls;
+
+///
+/// 줄 번호 홈통 — 번호를 TextBox 가 알려 준 줄 위치에 그린다.
+///
+/// 왜 TextBlock 한 줄로 두면 안 되는가. 예전에는 "1\n2\n3…" 을 TextBlock 에 넣어 두었다.
+/// 그러면 번호의 줄 간격은 TextBlock 의 조판이 정하고 본문의 줄 간격은 TextBox 가 정한다 —
+/// 글꼴 대체(한글)나 여백이 조금만 달라도 아래로 갈수록 벌어져 번호와 줄이 어긋난다.
+///
+/// 여기서는 각 줄의 첫 글자 위치를 로 물어
+/// 그 y 에 번호를 그린다. 스크롤·여백·글꼴 대체가 전부 TextBox 기준으로 반영돼 오므로 어긋날 수 없다.
+///
+public sealed class LineNumberGutter : FrameworkElement
+{
+ #region Member Fields
+ private TextBox? source;
+ private ScrollViewer? scroller;
+ #endregion
+
+ #region Properties
+ /// 줄 위치를 물어볼 대상
+ public TextBox? Source
+ {
+ get => source;
+ set
+ {
+ if (ReferenceEquals(source, value))
+ {
+ return;
+ }
+ Detach();
+ source = value;
+ Attach();
+ InvalidateVisual();
+ }
+ }
+
+ /// 오른쪽 여백 — 구분선과 번호 사이
+ public double RightPadding { get; set; } = 10;
+ #endregion
+
+ #region Methods
+ private void Attach()
+ {
+ if (source is null)
+ {
+ return;
+ }
+ source.TextChanged += OnChanged;
+ source.SizeChanged += OnChanged;
+ source.Loaded += OnLoaded;
+ HookScroller();
+ }
+
+ private void Detach()
+ {
+ if (source is not null)
+ {
+ source.TextChanged -= OnChanged;
+ source.SizeChanged -= OnChanged;
+ source.Loaded -= OnLoaded;
+ }
+ if (scroller is not null)
+ {
+ scroller.ScrollChanged -= OnScrolled;
+ scroller = null;
+ }
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ HookScroller();
+ InvalidateVisual();
+ }
+
+ 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 OnChanged(object sender, EventArgs e) => InvalidateVisual();
+
+ /// 가장 큰 번호가 들어갈 만큼만 폭을 잡는다 — 줄이 늘면 홈통도 넓어진다
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (source is null)
+ {
+ return new Size(28, 0);
+ }
+ var lines = 1;
+ foreach (var ch in source.Text)
+ {
+ if (ch == '\n')
+ {
+ lines++;
+ }
+ }
+ var probe = Measure(lines.ToString(CultureInfo.InvariantCulture));
+ return new Size(Math.Max(20, probe.Width) + RightPadding, availableSize.Height);
+ }
+
+ private FormattedText Measure(string text)
+ => new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
+ new Typeface(source!.FontFamily, source.FontStyle, source.FontWeight, source.FontStretch),
+ source.FontSize, Brushes.Black, VisualTreeHelper.GetDpi(this).PixelsPerDip);
+
+ protected override void OnRender(DrawingContext dc)
+ {
+ if (source is null)
+ {
+ return;
+ }
+
+ var text = source.Text;
+ var brush = TryFindResource("B.Muted") as Brush ?? Brushes.Gray;
+ var height = ActualHeight;
+
+ var lineIndex = 0;
+ var lineStart = 0;
+ while (true)
+ {
+ var rect = source.GetRectFromCharacterIndex(Math.Min(lineStart, text.Length));
+ if (!rect.IsEmpty && !double.IsInfinity(rect.Top) && !double.IsNaN(rect.Top)
+ && rect.Bottom >= 0 && rect.Top <= height)
+ {
+ var label = Measure((lineIndex + 1).ToString(CultureInfo.InvariantCulture));
+ label.SetForegroundBrush(brush);
+ // 오른쪽 정렬 — 자릿수가 늘어도 번호 끝이 구분선에서 같은 거리를 유지한다
+ dc.DrawText(label, new Point(ActualWidth - RightPadding - label.Width, rect.Y));
+ }
+
+ var newline = text.IndexOf('\n', lineStart);
+ if (newline < 0)
+ {
+ break;
+ }
+ lineStart = newline + 1;
+ lineIndex++;
+ }
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index dafc25d..b757af4 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -700,7 +700,28 @@ public static class DbSmoke
$"blur 전 열림={openBeforeBlur}, blur 후 열림={popup.IsOpen}");
// ⑦ 바깥 클릭으로 닫히는 설정인지
- Check("바깥 클릭으로 닫히는 설정(StaysOpen=False)", !popup.StaysOpen);
+ Check("항목 클릭이 캡처에 먹히지 않는 설정(StaysOpen=True)", popup.StaysOpen);
+
+ // ⑦-2 목록 항목을 집으면 실제로 입력돼야 한다.
+ // 예전에는 클릭이 포커스를 가져가 LostFocus 가 팝업을 닫아 버려 아무것도 안 들어갔다.
+ // 마우스 라우팅 자체는 화면 없이 흉내 낼 수 없어(Ctrl+Enter 때와 같은 이유)
+ // 핸들러가 부르는 그 경로(PickCompletionForSmoke)를 태워 삽입까지 확인한다.
+ var list = (System.Windows.Controls.ListBox)window.FindName("CompletionList")!;
+ Type("SELECT * FROM E_Sht");
+ if (popup.IsOpen && list.Items.Count > 0)
+ {
+ var before = box.Text;
+ list.UpdateLayout();
+ var picked = window.PickCompletionForSmoke(0);
+ DrainDispatcher();
+ Check("목록 항목을 집으면 입력되고 목록이 닫힌다",
+ picked && box.Text.Length > before.Length && !popup.IsOpen,
+ $"'{before}' → '{box.Text}'");
+ }
+ else
+ {
+ Check("목록 항목을 집으면 입력되고 목록이 닫힌다", false, "검사 전에 목록이 열리지 않았다");
+ }
// ⑧ 색칠 레이어와 실제 글자 위치가 맞는지 — 한글이 섞이면 폰트 대체 때문에 어긋났었다.
// 레이어는 TextBox 에게 좌표를 물으므로, 같은 인덱스의 사각형이 곧 그리는 자리다.
diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
index 6f4b2fa..fd27ab7 100644
--- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs
+++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
@@ -116,6 +116,9 @@ public static class DialogShots
("03-query-editor", () => NewQueryEditor(designer,
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <>")),
("03b-query-trial", () => NewQueryEditorWithTrial(designer)),
+ // 한글이 섞인 쿼리 — 색칠 레이어와 줄번호가 한글에서 어긋나는지 눈으로 확인할 경로
+ ("03c-query-hangul", () => NewQueryEditor(designer,
+ "select 이름, 주소 from 환자정보\nwhere 상태 = '입원'\n-- 세 번째 줄 주석\nand 나이 >= 20")),
// 실제 카탈로그(384종)와 실제 제안 순위로 찍는다 — 표본 몇 개로는 분류·건수가 안 보인다
("04-tag-picker", () => new Views.TagPickerDialogView(
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)",
diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
index ab20785..4bb0a24 100644
--- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
+++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
@@ -333,23 +333,27 @@
-
-
+
-
+
-
+
+ StaysOpen="True" PopupAnimation="None" Focusable="False">
+ BorderBrush="{DynamicResource B.Line2}" CornerRadius="8" Padding="3"
+ PreviewMouseLeftButtonDown="OnCompletionPicked">
+ ScrollViewer.HorizontalScrollBarVisibility="Disabled">
diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
index 0f33790..25e87a0 100644
--- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
+++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
@@ -47,6 +47,9 @@ public partial class QueryEditorWindow : Window
///
private bool typing;
+ /// 한글 조합 중인가 — 조합 중에는 색칠을 접고 TextBox 가 제 글자를 그린다
+ private bool composing;
+
/// 목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다
private int completionEnd;
@@ -109,6 +112,11 @@ public partial class QueryEditorWindow : Window
{
Title = $"쿼리 편집 — {ownerLabel}";
Highlight.Source = SqlBox;
+ LineNumbers.Source = SqlBox;
+ // 한글 조합(IME) 동안에는 색칠을 잠시 접고 TextBox 가 제 글자를 그리게 한다 — 아래 설명
+ TextCompositionManager.AddTextInputStartHandler(SqlBox, OnComposing);
+ TextCompositionManager.AddTextInputUpdateHandler(SqlBox, OnComposing);
+ TextCompositionManager.AddTextInputHandler(SqlBox, OnComposed);
RebuildBucketChips();
ApplyVariableFilter(string.Empty);
var connection = Services.ConfigService.Current.ConnectionString;
@@ -228,7 +236,14 @@ public partial class QueryEditorWindow : Window
}
}
- private void OnSqlLostFocus(object sender, RoutedEventArgs e) => CompletionPopup.IsOpen = false;
+ private void OnSqlLostFocus(object sender, RoutedEventArgs e) => CloseOnBlur();
+
+ private void CloseOnBlur()
+ {
+ CompletionPopup.IsOpen = false;
+ // 조합 도중 포커스를 잃으면 조합 끝 신호가 오지 않는다 — 색칠 없는 상태로 굳지 않게 되돌린다
+ ShowRawText(false);
+ }
///
/// 스크롤로 자리가 움직였을 때 목록을 새 자리에 다시 놓는다.
@@ -374,7 +389,49 @@ public partial class QueryEditorWindow : Window
Refresh();
}
- private void OnCompletionPicked(object sender, MouseButtonEventArgs e) => AcceptCompletion();
+ ///
+ /// 목록 항목 클릭 — 누르는 즉시 넣는다.
+ ///
+ /// 예전에는 더블클릭이었는데, 클릭이 편집기에서 포커스를 가져가면서 LostFocus 가 팝업을 닫아
+ /// 아무것도 입력되지 않고 사라졌다. 여기서 눌린 항목을 직접 찾아 처리하고 이벤트를 소비해
+ /// 포커스가 아예 옮겨 가지 않게 한다.
+ ///
+ private void OnCompletionPicked(object sender, MouseButtonEventArgs e)
+ {
+ if (e.OriginalSource is DependencyObject origin && PickCompletionAt(origin))
+ {
+ // 소비하지 않으면 ListBoxItem 이 포커스를 가져가고 편집기가 LostFocus 를 받는다
+ e.Handled = true;
+ SqlBox.Focus();
+ }
+ }
+
+ ///
+ /// 눌린 지점에서 항목을 찾아 넣는다 — 처리했으면 true.
+ /// 클릭 지점은 항목 안의 글자일 수도, 항목 컨테이너 자체일 수도 있다.
+ ///
+ private bool PickCompletionAt(DependencyObject origin)
+ {
+ var container = ItemsControl.ContainerFromElement(CompletionList, origin) as ListBoxItem;
+ if (container?.DataContext is not SqlCompletionItem item)
+ {
+ return false;
+ }
+ CompletionList.SelectedItem = item;
+ AcceptCompletion();
+ return true;
+ }
+
+ ///
+ /// 진단(--query-popup)에서 '항목 클릭'을 부르는 통로.
+ /// WPF 의 마우스 라우팅은 화면 없이 흉내 낼 수 없어(합성 이벤트가 캡처·히트테스트를 못 거친다)
+ /// 핸들러 본문을 그대로 태워 삽입 로직만 확인한다.
+ ///
+ internal bool PickCompletionForSmoke(int index)
+ {
+ var container = CompletionList.ItemContainerGenerator.ContainerFromIndex(index);
+ return container is DependencyObject origin && PickCompletionAt(origin);
+ }
/// 목록이 떠 있는 동안의 키 처리 — 처리했으면 true
private bool HandleCompletionKey(KeyEventArgs e)
@@ -494,6 +551,36 @@ public partial class QueryEditorWindow : Window
#endregion
#region Methods - Editing
+ ///
+ /// 한글을 치는 동안(IME 조합 중) 글자가 사라지던 문제.
+ ///
+ /// 색칠은 "투명한 TextBox 뒤에 우리가 그린다"로 되어 있다. 그런데 조합 중인 글자는
+ /// 아직 에 들어오지 않는다 — TextChanged 가 안 오니 우리는 그릴 수 없고,
+ /// TextBox 는 제 Foreground(투명)로 그리니 아무것도 안 보인다. 자음·모음을 맞추는 내내 빈 화면이었다.
+ ///
+ /// 그래서 조합이 시작되면 TextBox 에게 잉크색을 돌려주고 색칠 층을 감춘다. 조합이 끝나
+ /// 글자가 확정되면(TextInput → 이어서 TextChanged) 원래대로 되돌린다. 조합 중 잠깐 단색이 되는
+ /// 것이 글자가 안 보이는 것보다 낫다.
+ ///
+ private void OnComposing(object? sender, TextCompositionEventArgs e)
+ // 조합이 취소되면(ESC) CompositionText 가 비면서 여기로 온다 — 그때는 바로 색칠로 돌아간다
+ => ShowRawText(e.TextComposition.CompositionText.Length > 0);
+
+ private void OnComposed(object? sender, TextCompositionEventArgs e) => ShowRawText(false);
+
+ private void ShowRawText(bool raw)
+ {
+ if (composing == raw)
+ {
+ return;
+ }
+ composing = raw;
+ SqlBox.Foreground = raw
+ ? (System.Windows.Media.Brush)FindResource("B.Ink")
+ : System.Windows.Media.Brushes.Transparent;
+ Highlight.Visibility = raw ? Visibility.Hidden : Visibility.Visible;
+ }
+
private void OnSqlChanged(object sender, TextChangedEventArgs e)
{
Refresh();
@@ -516,7 +603,6 @@ public partial class QueryEditorWindow : Window
var text = SqlBox.Text;
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
UpdateCaretText();
- UpdateLineNumbers(text);
UpdateProblems(text);
}
@@ -581,17 +667,6 @@ public partial class QueryEditorWindow : Window
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();
- }
-
///
/// 저장 전에 눈에 띄어야 하는 것만 알린다 — 닫히지 않은 조각과 축약된 치환 변수.
///