From 7ce7506286d042d5519fc7f79be61ac949e38cef Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 13 Aug 2026 15:37:05 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=E2=80=94=20=EC=86=8C=EB=A9=B8=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EA=B3=BC=20=EC=9C=84=EC=B9=98,=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B3=A0=20=EA=B7=B8=EA=B1=B8=20=EC=9E=AC=EB=8A=94=20=EC=A7=84?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 가지 신고를 고쳤다: 위치가 안 맞고, 항목을 고르기 전까지 사라지지 않는다. ■ 사라지지 않던 문제 StaysOpen=True 로 두고 있었다. 편집 포커스를 잃지 않으려고 그렇게 했는데, 그 값은 바깥을 클릭해도 닫히지 않는다는 뜻이다. 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 --- src/SheetMe.Designer/App.xaml.cs | 5 + src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 111 ++++++++++++++++++ .../Views/QueryEditorWindow.xaml | 5 +- .../Views/QueryEditorWindow.xaml.cs | 70 ++++++++++- 4 files changed, 187 insertions(+), 4 deletions(-) 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()