쿼리 편집기 세 가지 — 한글이 사라지던 것, 줄 번호가 밀리던 것, 클릭이 먹지 않던 것

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 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 17:32:48 +09:00
co-authored by Claude Opus 5
parent 6ce055b00c
commit 749de6b566
5 changed files with 301 additions and 24 deletions
@@ -0,0 +1,174 @@
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 줄 번호 홈통 — 번호를 <b>TextBox 가 알려 준 줄 위치</b>에 그린다.
///
/// <b>왜 TextBlock 한 줄로 두면 안 되는가.</b> 예전에는 "1\n2\n3…" 을 TextBlock 에 넣어 두었다.
/// 그러면 번호의 줄 간격은 TextBlock 의 조판이 정하고 본문의 줄 간격은 TextBox 가 정한다 —
/// 글꼴 대체(한글)나 여백이 조금만 달라도 아래로 갈수록 벌어져 번호와 줄이 어긋난다.
///
/// 여기서는 각 줄의 첫 글자 위치를 <see cref="TextBox.GetRectFromCharacterIndex(int)"/> 로 물어
/// 그 y 에 번호를 그린다. 스크롤·여백·글꼴 대체가 전부 TextBox 기준으로 반영돼 오므로 어긋날 수 없다.
/// </summary>
public sealed class LineNumberGutter : FrameworkElement
{
#region Member Fields
private TextBox? source;
private ScrollViewer? scroller;
#endregion
#region Properties
/// <summary>줄 위치를 물어볼 대상</summary>
public TextBox? Source
{
get => source;
set
{
if (ReferenceEquals(source, value))
{
return;
}
Detach();
source = value;
Attach();
InvalidateVisual();
}
}
/// <summary>오른쪽 여백 — 구분선과 번호 사이</summary>
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();
/// <summary>가장 큰 번호가 들어갈 만큼만 폭을 잡는다 — 줄이 늘면 홈통도 넓어진다</summary>
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
}
+22 -1
View File
@@ -700,7 +700,28 @@ public static class DbSmoke
$"blur 전 열림={openBeforeBlur}, blur 후 열림={popup.IsOpen}"); $"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 에게 좌표를 물으므로, 같은 인덱스의 사각형이 곧 그리는 자리다. // 레이어는 TextBox 에게 좌표를 물으므로, 같은 인덱스의 사각형이 곧 그리는 자리다.
@@ -116,6 +116,9 @@ public static class DialogShots
("03-query-editor", () => NewQueryEditor(designer, ("03-query-editor", () => NewQueryEditor(designer,
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")), "SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
("03b-query-trial", () => NewQueryEditorWithTrial(designer)), ("03b-query-trial", () => NewQueryEditorWithTrial(designer)),
// 한글이 섞인 쿼리 — 색칠 레이어와 줄번호가 한글에서 어긋나는지 눈으로 확인할 경로
("03c-query-hangul", () => NewQueryEditor(designer,
"select 이름, 주소 from 환자정보\nwhere 상태 = '입원'\n-- 세 번째 줄 주석\nand 나이 >= 20")),
// 실제 카탈로그(384종)와 실제 제안 순위로 찍는다 — 표본 몇 개로는 분류·건수가 안 보인다 // 실제 카탈로그(384종)와 실제 제안 순위로 찍는다 — 표본 몇 개로는 분류·건수가 안 보인다
("04-tag-picker", () => new Views.TagPickerDialogView( ("04-tag-picker", () => new Views.TagPickerDialogView(
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", "데이터 태그 선택 — 자동 채움 원천(bzDataInterface)",
@@ -333,23 +333,27 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- 줄 번호 홈통 — 회색 면 + 곧은 세로 구분선 --> <!-- 줄 번호 홈통 — 회색 면 + 곧은 세로 구분선.
<Border Grid.Column="0" Background="{DynamicResource B.Chip}" Padding="12,10,10,10" 번호는 TextBox 가 알려 준 줄 위치에 그린다(TextBlock 한 덩어리로 두면
본문과 줄 간격이 달라 아래로 갈수록 어긋난다). -->
<Border Grid.Column="0" Background="{DynamicResource B.Chip}" Padding="12,0,0,0"
BorderThickness="0,0,1,0" BorderBrush="{DynamicResource B.Line2}"> BorderThickness="0,0,1,0" BorderBrush="{DynamicResource B.Line2}">
<TextBlock x:Name="LineNumbers" FontFamily="D2Coding, 굴림체, Consolas" FontSize="13.5" <ctl:LineNumberGutter x:Name="LineNumbers" IsHitTestVisible="False"/>
Foreground="{DynamicResource B.Muted}" TextAlignment="Right" MinWidth="20"/>
</Border> </Border>
<Grid Grid.Column="1"> <Grid Grid.Column="1">
<ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/> <ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/>
<!-- StaysOpen=False 여야 바깥을 클릭했을 때 닫힌다 --> <!-- StaysOpen=True 다. False 로 두면 Popup 이 마우스를 캡처해
항목을 누른 그 클릭이 '바깥 클릭'으로 먼저 소비돼 목록만 닫히고
아무것도 입력되지 않는다. 대신 커서 이동·포커스 상실·스크롤·창 비활성에서
코드가 직접 닫는다(위 핸들러들). -->
<Popup x:Name="CompletionPopup" Placement="Relative" AllowsTransparency="True" <Popup x:Name="CompletionPopup" Placement="Relative" AllowsTransparency="True"
StaysOpen="False" PopupAnimation="None" Focusable="False"> StaysOpen="True" PopupAnimation="None" Focusable="False">
<Border Background="{DynamicResource B.Surface}" BorderThickness="1" <Border Background="{DynamicResource B.Surface}" BorderThickness="1"
BorderBrush="{DynamicResource B.InputBorder}" CornerRadius="8" Padding="3"> BorderBrush="{DynamicResource B.Line2}" CornerRadius="8" Padding="3"
PreviewMouseLeftButtonDown="OnCompletionPicked">
<ListBox x:Name="CompletionList" MaxHeight="260" MinWidth="280" FontSize="12" <ListBox x:Name="CompletionList" MaxHeight="260" MinWidth="280" FontSize="12"
BorderThickness="0" Background="Transparent" BorderThickness="0" Background="Transparent"
ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.HorizontalScrollBarVisibility="Disabled">
MouseDoubleClick="OnCompletionPicked">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<DockPanel MinWidth="260"> <DockPanel MinWidth="260">
@@ -47,6 +47,9 @@ public partial class QueryEditorWindow : Window
/// </summary> /// </summary>
private bool typing; private bool typing;
/// <summary>한글 조합 중인가 — 조합 중에는 색칠을 접고 TextBox 가 제 글자를 그린다</summary>
private bool composing;
/// <summary>목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다</summary> /// <summary>목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다</summary>
private int completionEnd; private int completionEnd;
@@ -109,6 +112,11 @@ public partial class QueryEditorWindow : Window
{ {
Title = $"쿼리 편집 — {ownerLabel}"; Title = $"쿼리 편집 — {ownerLabel}";
Highlight.Source = SqlBox; Highlight.Source = SqlBox;
LineNumbers.Source = SqlBox;
// 한글 조합(IME) 동안에는 색칠을 잠시 접고 TextBox 가 제 글자를 그리게 한다 — 아래 설명
TextCompositionManager.AddTextInputStartHandler(SqlBox, OnComposing);
TextCompositionManager.AddTextInputUpdateHandler(SqlBox, OnComposing);
TextCompositionManager.AddTextInputHandler(SqlBox, OnComposed);
RebuildBucketChips(); RebuildBucketChips();
ApplyVariableFilter(string.Empty); ApplyVariableFilter(string.Empty);
var connection = Services.ConfigService.Current.ConnectionString; 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);
}
/// <summary> /// <summary>
/// 스크롤로 자리가 움직였을 때 목록을 새 자리에 다시 놓는다. /// 스크롤로 자리가 움직였을 때 목록을 새 자리에 다시 놓는다.
@@ -374,7 +389,49 @@ public partial class QueryEditorWindow : Window
Refresh(); Refresh();
} }
private void OnCompletionPicked(object sender, MouseButtonEventArgs e) => AcceptCompletion(); /// <summary>
/// 목록 항목 클릭 — 누르는 즉시 넣는다.
///
/// 예전에는 더블클릭이었는데, 클릭이 편집기에서 포커스를 가져가면서 LostFocus 가 팝업을 닫아
/// 아무것도 입력되지 않고 사라졌다. 여기서 눌린 항목을 직접 찾아 처리하고 이벤트를 소비해
/// 포커스가 아예 옮겨 가지 않게 한다.
/// </summary>
private void OnCompletionPicked(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is DependencyObject origin && PickCompletionAt(origin))
{
// 소비하지 않으면 ListBoxItem 이 포커스를 가져가고 편집기가 LostFocus 를 받는다
e.Handled = true;
SqlBox.Focus();
}
}
/// <summary>
/// 눌린 지점에서 항목을 찾아 넣는다 — 처리했으면 true.
/// 클릭 지점은 항목 안의 글자일 수도, 항목 컨테이너 자체일 수도 있다.
/// </summary>
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;
}
/// <summary>
/// 진단(--query-popup)에서 '항목 클릭'을 부르는 통로.
/// WPF 의 마우스 라우팅은 화면 없이 흉내 낼 수 없어(합성 이벤트가 캡처·히트테스트를 못 거친다)
/// 핸들러 본문을 그대로 태워 삽입 로직만 확인한다.
/// </summary>
internal bool PickCompletionForSmoke(int index)
{
var container = CompletionList.ItemContainerGenerator.ContainerFromIndex(index);
return container is DependencyObject origin && PickCompletionAt(origin);
}
/// <summary>목록이 떠 있는 동안의 키 처리 — 처리했으면 true</summary> /// <summary>목록이 떠 있는 동안의 키 처리 — 처리했으면 true</summary>
private bool HandleCompletionKey(KeyEventArgs e) private bool HandleCompletionKey(KeyEventArgs e)
@@ -494,6 +551,36 @@ public partial class QueryEditorWindow : Window
#endregion #endregion
#region Methods - Editing #region Methods - Editing
/// <summary>
/// 한글을 치는 동안(IME 조합 중) 글자가 사라지던 문제.
///
/// 색칠은 "투명한 TextBox 뒤에 우리가 그린다"로 되어 있다. 그런데 조합 중인 글자는
/// 아직 <see cref="TextBox.Text"/> 에 들어오지 않는다 — TextChanged 가 안 오니 우리는 그릴 수 없고,
/// TextBox 는 제 Foreground(투명)로 그리니 아무것도 안 보인다. 자음·모음을 맞추는 내내 빈 화면이었다.
///
/// 그래서 조합이 시작되면 TextBox 에게 잉크색을 돌려주고 색칠 층을 감춘다. 조합이 끝나
/// 글자가 확정되면(TextInput → 이어서 TextChanged) 원래대로 되돌린다. 조합 중 잠깐 단색이 되는
/// 것이 글자가 안 보이는 것보다 낫다.
/// </summary>
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) private void OnSqlChanged(object sender, TextChangedEventArgs e)
{ {
Refresh(); Refresh();
@@ -516,7 +603,6 @@ public partial class QueryEditorWindow : Window
var text = SqlBox.Text; var text = SqlBox.Text;
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄"; LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
UpdateCaretText(); UpdateCaretText();
UpdateLineNumbers(text);
UpdateProblems(text); UpdateProblems(text);
} }
@@ -581,17 +667,6 @@ public partial class QueryEditorWindow : Window
return 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> /// <summary>
/// 저장 전에 눈에 띄어야 하는 것만 알린다 — 닫히지 않은 조각과 축약된 치환 변수. /// 저장 전에 눈에 띄어야 하는 것만 알린다 — 닫히지 않은 조각과 축약된 치환 변수.
/// ///