From 749de6b5662d0a2f2dd44d13aa49cc2d6a101640 Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 13 Aug 2026 17:32:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20=EC=84=B8=20=EA=B0=80=EC=A7=80=20=E2=80=94=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=EC=9D=B4=20=EC=82=AC=EB=9D=BC=EC=A7=80?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83,=20=EC=A4=84=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EA=B0=80=20=EB=B0=80=EB=A6=AC=EB=8D=98=20=EA=B2=83,=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=EC=9D=B4=20=EB=A8=B9=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 한글로 입력하면 글자가 안 보였다. 색칠은 "투명한 TextBox 뒤에 우리가 그린다"로 되어 있다. 그런데 IME 조합 중인 글자는 아직 TextBox.Text 에 없다 — TextChanged 가 오지 않으니 우리는 그릴 수 없고, TextBox 는 제 Foreground(투명)로 그리니 아무것도 안 보인다. 자음·모음을 맞추는 내내 빈 화면이었고, 확정하고 나서야 나타났다. 조합이 시작되면 TextBox 에게 잉크색을 돌려주고 색칠 층을 감춘다(ShowRawText). 확정되면(TextInput) 되돌리고, ESC 로 취소돼 CompositionText 가 비어도 되돌린다. 조합 도중 포커스를 잃으면 끝 신호가 오지 않으므로 blur 에서도 되돌린다 — 색 없는 상태로 굳지 않게. 조합 중 잠깐 단색이 되는 편이 글자가 안 보이는 것보다 낫다. 2) 줄 번호가 실제 줄과 어긋났다. 번호를 "1\n2\n3…" TextBlock 한 덩어리로 두면 번호의 줄 간격은 TextBlock 조판이, 본문의 줄 간격은 TextBox 가 정한다. 한글 글꼴 대체가 끼면 조금씩 벌어져 아래로 갈수록 눈에 띄게 밀렸다. LineNumberGutter 를 만들어 각 줄 첫 글자의 y 를 GetRectFromCharacterIndex 로 물어 그 자리에 번호를 그린다. 스크롤·여백·폰트 대체가 전부 TextBox 기준으로 반영돼 오므로 구조적으로 어긋날 수 없다. 폭은 가장 큰 번호에 맞춰 잡고 번호는 오른쪽 정렬한다. 3) 자동완성 목록의 항목을 클릭하면 아무것도 안 들어가고 목록만 닫혔다. Popup 이 StaysOpen=False 라 마우스를 캡처하고 있었다. 항목을 누른 그 클릭이 '바깥 클릭'으로 먼저 소비돼 팝업이 닫히고, 항목은 클릭을 받지 못했다. StaysOpen=True 로 바꾸고 닫는 일은 코드가 맡는다(커서 이동·포커스 상실·창 비활성 — 이미 있던 핸들러들). 집는 처리는 팝업 안쪽 Border 에서 터널 단계로 받는다. 진단(--query-popup)에 "목록 항목을 집으면 입력되고 목록이 닫힌다"를 더해 17건. 마우스 라우팅 자체는 화면 없이 흉내 낼 수 없어(Ctrl+Enter 때와 같은 이유) 핸들러가 부르는 경로를 그대로 태워 삽입까지 확인한다. IME 조합은 합성할 수 없어 1) 은 실기 확인이 필요하다. 게이트: 테스트 230/230, --edit-smoke 0건, --db-smoke 3000 diff 0·예외 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일. Co-Authored-By: Claude Opus 5 --- .../Controls/LineNumberGutter.cs | 174 ++++++++++++++++++ src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 23 ++- .../Diagnostics/DialogShots.cs | 3 + .../Views/QueryEditorWindow.xaml | 22 ++- .../Views/QueryEditorWindow.xaml.cs | 103 +++++++++-- 5 files changed, 301 insertions(+), 24 deletions(-) create mode 100644 src/SheetMe.Designer/Controls/LineNumberGutter.cs 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(); - } - /// /// 저장 전에 눈에 띄어야 하는 것만 알린다 — 닫히지 않은 조각과 축약된 치환 변수. ///