자동완성 팝업 — 소멸 규칙과 위치, 그리고 그걸 재는 진단
두 가지 신고를 고쳤다: 위치가 안 맞고, 항목을 고르기 전까지 사라지지 않는다. ■ 사라지지 않던 문제 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:
co-authored by
Claude Opus 5
parent
f7c90979b9
commit
7ce7506286
@@ -222,6 +222,11 @@ public partial class App : Application
|
|||||||
return Diagnostics.DbSmoke.RunTableSearch(args[1], args[2]);
|
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")
|
if (args.Length >= 2 && args[0] == "--db-trial")
|
||||||
{
|
{
|
||||||
return Diagnostics.DbSmoke.RunQueryTrial(args[1]);
|
return Diagnostics.DbSmoke.RunQueryTrial(args[1]);
|
||||||
|
|||||||
@@ -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>
|
/// <summary>
|
||||||
/// 쿼리 검증 실행 점검(--db-trial) — 편집기의 '검증 실행'이 실제 DB 에서 무엇을 돌려주는지.
|
/// 쿼리 검증 실행 점검(--db-trial) — 편집기의 '검증 실행'이 실제 DB 에서 무엇을 돌려주는지.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -68,8 +68,10 @@
|
|||||||
<Grid Grid.Column="1">
|
<Grid Grid.Column="1">
|
||||||
<ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/>
|
<ctl:SqlHighlightLayer x:Name="Highlight" IsHitTestVisible="False"/>
|
||||||
<!-- 자동완성 목록 — 커서 아래에 띄운다. StaysOpen 이라 목록을 눌러도 편집 포커스를 잃지 않는다 -->
|
<!-- 자동완성 목록 — 커서 아래에 띄운다. StaysOpen 이라 목록을 눌러도 편집 포커스를 잃지 않는다 -->
|
||||||
|
<!-- StaysOpen=False 여야 바깥을 클릭했을 때 닫힌다.
|
||||||
|
True 로 두면 목록 항목을 고르기 전까지 화면에 계속 남는다. -->
|
||||||
<Popup x:Name="CompletionPopup" Placement="Relative" AllowsTransparency="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"
|
<Border Background="{DynamicResource B.Surface}" BorderThickness="1"
|
||||||
BorderBrush="{DynamicResource B.InputBorder}" CornerRadius="6" Padding="2">
|
BorderBrush="{DynamicResource B.InputBorder}" CornerRadius="6" Padding="2">
|
||||||
<ListBox x:Name="CompletionList" MaxHeight="240" MinWidth="260" FontSize="12"
|
<ListBox x:Name="CompletionList" MaxHeight="240" MinWidth="260" FontSize="12"
|
||||||
@@ -100,6 +102,7 @@
|
|||||||
SelectionBrush="{DynamicResource B.Accent}" SelectionOpacity="0.35"
|
SelectionBrush="{DynamicResource B.Accent}" SelectionOpacity="0.35"
|
||||||
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
|
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
|
||||||
TextChanged="OnSqlChanged" PreviewKeyDown="OnSqlKeyDown"
|
TextChanged="OnSqlChanged" PreviewKeyDown="OnSqlKeyDown"
|
||||||
|
SelectionChanged="OnSqlSelectionChanged" LostFocus="OnSqlLostFocus"
|
||||||
AutomationProperties.Name="SQL 편집"/>
|
AutomationProperties.Name="SQL 편집"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ public partial class QueryEditorWindow : Window
|
|||||||
|
|
||||||
/// <summary>자동완성이 텍스트를 고치는 중 — TextChanged 재진입을 막는다</summary>
|
/// <summary>자동완성이 텍스트를 고치는 중 — TextChanged 재진입을 막는다</summary>
|
||||||
private bool applyingCompletion;
|
private bool applyingCompletion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 방금 글자를 쳐서 목록이 갱신됐는지 — 커서만 움직인 경우와 구분한다.
|
||||||
|
/// 타이핑이면 목록을 다시 계산하고, 그냥 커서를 옮긴 것이면 닫는다.
|
||||||
|
/// </summary>
|
||||||
|
private bool typing;
|
||||||
|
|
||||||
|
/// <summary>목록이 걸려 있는 낱말의 끝 — 커서가 이 범위를 벗어나면 목록은 더 이상 유효하지 않다</summary>
|
||||||
|
private int completionEnd;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
@@ -81,6 +90,12 @@ public partial class QueryEditorWindow : Window
|
|||||||
VariableColumn.Width = new GridLength(0);
|
VariableColumn.Width = new GridLength(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 편집기가 스크롤되면 목록이 걸린 자리가 화면에서 움직인다 — 따라가지 않고 닫는다
|
||||||
|
SqlBox.AddHandler(ScrollViewer.ScrollChangedEvent,
|
||||||
|
new ScrollChangedEventHandler((_, _) => CompletionPopup.IsOpen = false));
|
||||||
|
// 창을 벗어나면 목록이 다른 창 위에 떠 있게 된다
|
||||||
|
Deactivated += (_, _) => CompletionPopup.IsOpen = false;
|
||||||
|
|
||||||
Loaded += (_, _) =>
|
Loaded += (_, _) =>
|
||||||
{
|
{
|
||||||
SqlBox.Focus();
|
SqlBox.Focus();
|
||||||
@@ -91,6 +106,13 @@ public partial class QueryEditorWindow : Window
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods - Completion
|
#region Methods - Completion
|
||||||
|
/// <summary>
|
||||||
|
/// 진단(--query-popup)에서 목록 갱신을 부르는 통로.
|
||||||
|
/// 프로그램으로 Text 를 넣으면 TextChanged 시점의 커서가 아직 옛 자리라
|
||||||
|
/// 사용자가 친 것과 같은 상태를 만들 수 없다.
|
||||||
|
/// </summary>
|
||||||
|
internal void RefreshCompletionForSmoke() => UpdateCompletion();
|
||||||
|
|
||||||
/// <summary>커서 위치를 보고 목록을 띄우거나 닫는다</summary>
|
/// <summary>커서 위치를 보고 목록을 띄우거나 닫는다</summary>
|
||||||
private void UpdateCompletion()
|
private void UpdateCompletion()
|
||||||
{
|
{
|
||||||
@@ -117,6 +139,7 @@ public partial class QueryEditorWindow : Window
|
|||||||
// 기준점이 바뀐 경우에만 닫았다 다시 연다.
|
// 기준점이 바뀐 경우에만 닫았다 다시 연다.
|
||||||
var anchorChanged = completionStart != query.ReplaceStart;
|
var anchorChanged = completionStart != query.ReplaceStart;
|
||||||
completionStart = query.ReplaceStart;
|
completionStart = query.ReplaceStart;
|
||||||
|
completionEnd = SqlBox.CaretIndex;
|
||||||
CompletionList.ItemsSource = items;
|
CompletionList.ItemsSource = items;
|
||||||
CompletionList.SelectedIndex = 0;
|
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)
|
private IEnumerable<SqlCompletionItem> CandidatesFor(SqlCompletionQuery query)
|
||||||
{
|
{
|
||||||
switch (query.Context)
|
switch (query.Context)
|
||||||
@@ -204,10 +253,14 @@ public partial class QueryEditorWindow : Window
|
|||||||
{
|
{
|
||||||
rect = new Rect(0, 0, 0, SqlBox.FontSize * 1.4);
|
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.PlacementTarget = SqlBox;
|
||||||
CompletionPopup.Placement = System.Windows.Controls.Primitives.PlacementMode.Relative;
|
CompletionPopup.Placement = System.Windows.Controls.Primitives.PlacementMode.Relative;
|
||||||
CompletionPopup.HorizontalOffset = Math.Max(0, rect.X);
|
CompletionPopup.HorizontalOffset = Math.Clamp(rect.X, 0, maxX);
|
||||||
CompletionPopup.VerticalOffset = Math.Max(0, rect.Bottom) + 2;
|
CompletionPopup.VerticalOffset = Math.Clamp(rect.Bottom + 2, 0, maxY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>고른 후보를 커서 앞 낱말과 바꿔 넣는다</summary>
|
/// <summary>고른 후보를 커서 앞 낱말과 바꿔 넣는다</summary>
|
||||||
@@ -358,8 +411,19 @@ public partial class QueryEditorWindow : Window
|
|||||||
private void OnSqlChanged(object sender, TextChangedEventArgs e)
|
private void OnSqlChanged(object sender, TextChangedEventArgs e)
|
||||||
{
|
{
|
||||||
Refresh();
|
Refresh();
|
||||||
|
// TextChanged 다음에 SelectionChanged 가 이어서 오는데, 그건 타이핑에 따른 커서 이동이라
|
||||||
|
// '커서만 옮겼다'로 오인해 목록을 닫으면 안 된다.
|
||||||
|
typing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
UpdateCompletion();
|
UpdateCompletion();
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Dispatcher.BeginInvoke(new Action(() => typing = false),
|
||||||
|
System.Windows.Threading.DispatcherPriority.Input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void Refresh()
|
private void Refresh()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user