diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs
index 56da725..2e3435e 100644
--- a/src/SheetMe.Designer/App.xaml.cs
+++ b/src/SheetMe.Designer/App.xaml.cs
@@ -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]);
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index b028c00..49d92db 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -615,6 +615,117 @@ public static class DbSmoke
}
}
+ ///
+ /// 자동완성 팝업 동작 점검(--query-popup) — 위치와 소멸 규칙을 실제 창을 띄워 잰다.
+ ///
+ /// 이 둘은 눈으로만 보면 놓친다. 팝업이 옛 문맥을 물고 남아 있거나 엉뚱한 자리에 뜨는 문제가
+ /// 실제로 두 번 있었다(커서 이동 시 갱신 안 됨 / 열린 채로는 Placement 재계산 안 됨).
+ ///
+ public static int RunQueryPopupCheck(string reportPath)
+ {
+ var lines = new List();
+ 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;
+ }
+
+ /// 대기 중인 디스패처 작업을 비운다 — 팝업 갱신이 Input 우선순위로 예약된다
+ 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);
+ }
+
///
/// 쿼리 검증 실행 점검(--db-trial) — 편집기의 '검증 실행'이 실제 DB 에서 무엇을 돌려주는지.
///
diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
index a8010c7..107a3a4 100644
--- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
+++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
@@ -68,8 +68,10 @@
+
+ StaysOpen="False" PopupAnimation="None" Focusable="False">
diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
index 0a330bf..d213aff 100644
--- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
+++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
@@ -40,6 +40,15 @@ public partial class QueryEditorWindow : Window
/// 자동완성이 텍스트를 고치는 중 — TextChanged 재진입을 막는다
private bool applyingCompletion;
+
+ ///
+ /// 방금 글자를 쳐서 목록이 갱신됐는지 — 커서만 움직인 경우와 구분한다.
+ /// 타이핑이면 목록을 다시 계산하고, 그냥 커서를 옮긴 것이면 닫는다.
+ ///
+ private bool typing;
+
+ /// 목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다
+ 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
+ ///
+ /// 진단(--query-popup)에서 목록 갱신을 부르는 통로.
+ /// 프로그램으로 Text 를 넣으면 TextChanged 시점의 커서가 아직 옛 자리라
+ /// 사용자가 친 것과 같은 상태를 만들 수 없다.
+ ///
+ internal void RefreshCompletionForSmoke() => UpdateCompletion();
+
/// 커서 위치를 보고 목록을 띄우거나 닫는다
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
}
}
+ ///
+ /// 커서가 움직였다 — 타이핑이면 목록을 다시 계산하고, 그냥 옮긴 것이면 닫는다.
+ ///
+ /// 이걸 안 하면 옛 문맥의 목록이 그대로 남는다. 실제로
+ /// 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)
@@ -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);
}
/// 고른 후보를 커서 앞 낱말과 바꿔 넣는다
@@ -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()