자동완성 팝업 — 소멸 규칙과 위치, 그리고 그걸 재는 진단

두 가지 신고를 고쳤다: 위치가 안 맞고, 항목을 고르기 전까지 사라지지 않는다.

■ 사라지지 않던 문제

StaysOpen=True 로 두고 있었다. 편집 포커스를 잃지 않으려고 그렇게 했는데,
그 값은 <b>바깥을 클릭해도 닫히지 않는다</b>는 뜻이다. False 로 바꿨다.

더 큰 원인은 따로 있었다. 목록 갱신을 TextChanged 에서만 불렀다.
커서만 옮기면(방향키·클릭·다음 줄) 아무 일도 일어나지 않아, 옛 문맥의 목록이 그대로 남았다 —
FROM 뒤에서 테이블 목록을 띄운 뒤 다음 줄로 내려가도 그 테이블 목록이 계속 떠 있었다.
스크린샷의 상태가 정확히 이것이다.

이제 SelectionChanged 에서도 판정한다. 목록이 걸린 낱말 범위를 커서가 벗어나면 닫는다.
타이핑에 따른 커서 이동과 순수한 커서 이동을 구분해야 해서(TextChanged 뒤에 SelectionChanged 가
이어서 온다) 타이핑 표시를 Input 우선순위로 내려 두고 판정한다.
포커스를 잃을 때·편집기가 스크롤될 때·창이 비활성될 때도 닫는다.

■ 위치가 안 맞던 문제

셋이 겹쳐 있었다.
1. WPF Popup 은 열려 있는 동안 Placement 를 다시 계산하지 않는다 — 오프셋만 바꾸면 제자리에 머문다.
   기준점이 달라졌을 때만 닫았다 다시 연다.
2. GetRectFromCharacterIndex 가 범위 밖에서 Empty 를 준다 — 캐럿 → 원점 순으로 물러선다.
3. 스크롤 위치에 따라 rect 가 편집 영역을 크게 벗어난다.
   진단으로 재 보니 200줄짜리 문서에서 세로 오프셋이 3,074px 이었다(편집 높이는 494px).
   목록이 창 밖에 떠 있었다는 뜻이다. 편집 영역 안으로 제한했다.

기준점은 커서가 아니라 완성 중인 낱말의 시작이다 — 글자마다 흔들리지 않는다.

■ --query-popup 진단

이 종류는 눈으로만 보면 놓친다(두 번 놓쳤다). 실제 창을 화면 밖에 띄워 7가지를 잰다:
목록이 열리는가 · 같은 낱말을 이어 쳐도 기준점이 그대로인가 · 줄이 바뀌면 내려가는가 ·
커서를 옮기면 닫히는가 · 긴 문서에서도 편집 영역 안에 뜨는가 · 포커스를 잃으면 닫히는가 ·
바깥 클릭으로 닫히는 설정인가. 현재 7/7.

만들면서 결함이 하나 더 드러났다: 프로그램으로 Text 를 넣으면 TextChanged 시점의 커서가
아직 옛 자리라 사용자가 친 것과 같은 상태가 되지 않는다. 진단용 통로를 코드베이스 관례대로
(RemoveSelectedPageForSmoke 와 같은 형태) 열어 두고 그 자리에서 갱신을 부른다.

회귀: 테스트 216/216, 편집 스모크 실패 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:37:05 +09:00
co-authored by Claude Opus 5
parent f7c90979b9
commit 7ce7506286
4 changed files with 187 additions and 4 deletions
+5
View File
@@ -222,6 +222,11 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunTableSearch(args[1], args[2]);
}
if (args.Length >= 2 && args[0] == "--query-popup")
{
return Diagnostics.DbSmoke.RunQueryPopupCheck(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-trial")
{
return Diagnostics.DbSmoke.RunQueryTrial(args[1]);
+111
View File
@@ -615,6 +615,117 @@ public static class DbSmoke
}
}
/// <summary>
/// 자동완성 팝업 동작 점검(--query-popup) — 위치와 소멸 규칙을 실제 창을 띄워 잰다.
///
/// 이 둘은 눈으로만 보면 놓친다. 팝업이 옛 문맥을 물고 남아 있거나 엉뚱한 자리에 뜨는 문제가
/// 실제로 두 번 있었다(커서 이동 시 갱신 안 됨 / 열린 채로는 Placement 재계산 안 됨).
/// </summary>
public static int RunQueryPopupCheck(string reportPath)
{
var lines = new List<string>();
var failures = 0;
void Check(string label, bool ok, string detail = "")
{
if (!ok)
{
failures++;
}
lines.Add($"{(ok ? "PASS" : "FAIL")} {label}" + (detail.Length > 0 ? $" — {detail}" : string.Empty));
}
try
{
var window = new Views.QueryEditorWindow("점검", string.Empty);
// 화면 밖에 띄운다 — 진단이 사용자 화면을 가리지 않게
window.WindowStartupLocation = System.Windows.WindowStartupLocation.Manual;
window.Left = -10000;
window.Top = -10000;
window.Show();
window.UpdateLayout();
var box = (System.Windows.Controls.TextBox)window.FindName("SqlBox")!;
var popup = (System.Windows.Controls.Primitives.Popup)window.FindName("CompletionPopup")!;
void Type(string text, int? caret = null)
{
box.Text = text;
box.CaretIndex = caret ?? text.Length;
// Text 를 프로그램으로 넣으면 TextChanged 시점의 커서가 아직 옛 자리다 —
// 사용자가 친 것과 같은 상태로 맞춘 뒤 갱신을 부른다
window.RefreshCompletionForSmoke();
DrainDispatcher();
}
// ① FROM 뒤에서 테이블 목록이 뜬다
Type("SELECT * FROM P_");
Check("FROM 뒤 목록이 열린다", popup.IsOpen);
var firstOffset = (popup.HorizontalOffset, popup.VerticalOffset);
// ② 팝업은 완성 중인 낱말의 시작에 붙는다 — 캐럿을 따라 오른쪽으로 흐르면 안 된다
Type("SELECT * FROM P_PATINF");
Check("같은 낱말을 이어 쳐도 기준점이 그대로",
Math.Abs(popup.HorizontalOffset - firstOffset.HorizontalOffset) < 0.5,
$"{firstOffset.HorizontalOffset:F1} → {popup.HorizontalOffset:F1}");
// ③ 줄이 바뀌면 세로 위치도 따라간다
Type("SELECT * FROM P_PATINF\nWHERE PAT");
Check("다음 줄에서는 아래로 내려간다",
popup.VerticalOffset > firstOffset.VerticalOffset,
$"{firstOffset.VerticalOffset:F1} → {popup.VerticalOffset:F1}");
// ④ 커서만 옮기면 닫힌다 — 옛 문맥의 목록이 남아 있으면 안 된다
var wasOpen = popup.IsOpen;
box.CaretIndex = 3;
DrainDispatcher();
Check("커서를 옮기면 닫힌다", wasOpen && !popup.IsOpen);
// ⑤ 범위를 벗어난 좌표에서도 화면 안에 있어야 한다
Type(new string('\n', 200) + "SELECT * FROM P_");
// 목록이 편집 영역 밖에 뜨면 화면 구석에 떠 있는 것처럼 보인다
Check("긴 문서에서도 편집 영역 안에 뜬다",
!double.IsNaN(popup.VerticalOffset) && !double.IsInfinity(popup.VerticalOffset)
&& popup.VerticalOffset >= 0 && popup.VerticalOffset <= box.ActualHeight
&& popup.HorizontalOffset >= 0 && popup.HorizontalOffset <= box.ActualWidth,
$"세로 {popup.VerticalOffset:F1} / 편집 높이 {box.ActualHeight:F1}");
// ⑥ 포커스를 잃으면 닫힌다
Type("SELECT * FROM P_");
var openBeforeBlur = popup.IsOpen;
System.Windows.Input.Keyboard.ClearFocus();
box.RaiseEvent(new System.Windows.RoutedEventArgs(System.Windows.UIElement.LostFocusEvent));
DrainDispatcher();
Check("포커스를 잃으면 닫힌다", openBeforeBlur && !popup.IsOpen);
// ⑦ 바깥 클릭으로 닫히는 설정인지
Check("바깥 클릭으로 닫히는 설정(StaysOpen=False)", !popup.StaysOpen);
window.Close();
DrainDispatcher();
}
catch (Exception ex)
{
failures++;
lines.Add("예외: " + ex);
}
lines.Add(string.Empty);
lines.Add($"결과: 실패 {failures}건");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return failures == 0 ? 0 : 1;
}
/// <summary>대기 중인 디스패처 작업을 비운다 — 팝업 갱신이 Input 우선순위로 예약된다</summary>
private static void DrainDispatcher()
{
var frame = new System.Windows.Threading.DispatcherFrame();
System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(
System.Windows.Threading.DispatcherPriority.Background,
new Action(() => frame.Continue = false));
System.Windows.Threading.Dispatcher.PushFrame(frame);
}
/// <summary>
/// 쿼리 검증 실행 점검(--db-trial) — 편집기의 '검증 실행'이 실제 DB 에서 무엇을 돌려주는지.
///
@@ -68,8 +68,10 @@
<Grid Grid.Column="1">
<ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/>
<!-- 자동완성 목록 — 커서 아래에 띄운다. StaysOpen 이라 목록을 눌러도 편집 포커스를 잃지 않는다 -->
<!-- StaysOpen=False 여야 바깥을 클릭했을 때 닫힌다.
True 로 두면 목록 항목을 고르기 전까지 화면에 계속 남는다. -->
<Popup x:Name="CompletionPopup" Placement="Relative" AllowsTransparency="True"
StaysOpen="True" PopupAnimation="None">
StaysOpen="False" PopupAnimation="None" Focusable="False">
<Border Background="{DynamicResource B.Surface}" BorderThickness="1"
BorderBrush="{DynamicResource B.InputBorder}" CornerRadius="6" Padding="2">
<ListBox x:Name="CompletionList" MaxHeight="240" MinWidth="260" FontSize="12"
@@ -100,6 +102,7 @@
SelectionBrush="{DynamicResource B.Accent}" SelectionOpacity="0.35"
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
TextChanged="OnSqlChanged" PreviewKeyDown="OnSqlKeyDown"
SelectionChanged="OnSqlSelectionChanged" LostFocus="OnSqlLostFocus"
AutomationProperties.Name="SQL 편집"/>
</Grid>
</Grid>
@@ -40,6 +40,15 @@ public partial class QueryEditorWindow : Window
/// <summary>자동완성이 텍스트를 고치는 중 — TextChanged 재진입을 막는다</summary>
private bool applyingCompletion;
/// <summary>
/// 방금 글자를 쳐서 목록이 갱신됐는지 — 커서만 움직인 경우와 구분한다.
/// 타이핑이면 목록을 다시 계산하고, 그냥 커서를 옮긴 것이면 닫는다.
/// </summary>
private bool typing;
/// <summary>목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다</summary>
private int completionEnd;
#endregion
#region Properties
@@ -81,6 +90,12 @@ public partial class QueryEditorWindow : Window
VariableColumn.Width = new GridLength(0);
}
// 편집기가 스크롤되면 목록이 걸린 자리가 화면에서 움직인다 — 따라가지 않고 닫는다
SqlBox.AddHandler(ScrollViewer.ScrollChangedEvent,
new ScrollChangedEventHandler((_, _) => CompletionPopup.IsOpen = false));
// 창을 벗어나면 목록이 다른 창 위에 떠 있게 된다
Deactivated += (_, _) => CompletionPopup.IsOpen = false;
Loaded += (_, _) =>
{
SqlBox.Focus();
@@ -91,6 +106,13 @@ public partial class QueryEditorWindow : Window
#endregion
#region Methods - Completion
/// <summary>
/// 진단(--query-popup)에서 목록 갱신을 부르는 통로.
/// 프로그램으로 Text 를 넣으면 TextChanged 시점의 커서가 아직 옛 자리라
/// 사용자가 친 것과 같은 상태를 만들 수 없다.
/// </summary>
internal void RefreshCompletionForSmoke() => UpdateCompletion();
/// <summary>커서 위치를 보고 목록을 띄우거나 닫는다</summary>
private void UpdateCompletion()
{
@@ -117,6 +139,7 @@ public partial class QueryEditorWindow : Window
// 기준점이 바뀐 경우에만 닫았다 다시 연다.
var anchorChanged = completionStart != query.ReplaceStart;
completionStart = query.ReplaceStart;
completionEnd = SqlBox.CaretIndex;
CompletionList.ItemsSource = items;
CompletionList.SelectedIndex = 0;
@@ -131,6 +154,32 @@ public partial class QueryEditorWindow : Window
}
}
/// <summary>
/// 커서가 움직였다 — 타이핑이면 목록을 다시 계산하고, 그냥 옮긴 것이면 닫는다.
///
/// 이걸 안 하면 옛 문맥의 목록이 그대로 남는다. 실제로
/// <c>FROM </c> 에서 테이블 목록을 띄운 뒤 다음 줄로 내려가도 그 목록이 계속 떠 있었다.
/// </summary>
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<SqlCompletionItem> CandidatesFor(SqlCompletionQuery query)
{
switch (query.Context)
@@ -204,10 +253,14 @@ public partial class QueryEditorWindow : Window
{
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.Max(0, rect.X);
CompletionPopup.VerticalOffset = Math.Max(0, rect.Bottom) + 2;
CompletionPopup.HorizontalOffset = Math.Clamp(rect.X, 0, maxX);
CompletionPopup.VerticalOffset = Math.Clamp(rect.Bottom + 2, 0, maxY);
}
/// <summary>고른 후보를 커서 앞 낱말과 바꿔 넣는다</summary>
@@ -358,7 +411,18 @@ public partial class QueryEditorWindow : Window
private void OnSqlChanged(object sender, TextChangedEventArgs e)
{
Refresh();
UpdateCompletion();
// TextChanged 다음에 SelectionChanged 가 이어서 오는데, 그건 타이핑에 따른 커서 이동이라
// '커서만 옮겼다'로 오인해 목록을 닫으면 안 된다.
typing = true;
try
{
UpdateCompletion();
}
finally
{
Dispatcher.BeginInvoke(new Action(() => typing = false),
System.Windows.Threading.DispatcherPriority.Input);
}
}
private void Refresh()