태그 행에서 모달 없이 태그를 고른다 — 인라인 완성

4단계 계속. 주 경로에서 1080x700 모달이 사라진다.

칩 상위 8종만으로 운영 사용의 41.7%, 상위 16종으로 61.5% 를 덮는다.
나머지는 이름을 정확히 16자 외워 치거나 모달을 열어야 했고, 자유 입력은 열려 있는데
제안이 없어서 <b>오타가 검증 없이 저장되고 EMR 에서 조용히 빈 칸이 됐다</b>.
같은 인스펙터의 '속성 추가' 행에는 이미 부분일치 제안이 있었다 —
패턴은 있는데 운영 8,032건이 걸린 칸에만 없었다.

<b>새 패턴을 발명하지 않았다.</b> QueryEditorWindow 의 CompletionPopup 에서 배운 것을 그대로 쓴다:
StaysOpen=False 로 두면 팝업이 마우스를 캡처해 항목 클릭을 '바깥 클릭'으로 먹어 버린다
(눌러도 입력이 안 되고 사라진다 — 그 함정을 이미 한 번 겪었다).
AllowsTransparency 도 켜지 않는다 — 레이어드 창이 되면 ClearType 이 꺼져 13px 한글이 뭉개진다
(진단 cleartype 의 D 표본). 대신 안쪽에 ClearTypeHint 를 건다.

<b>칩이 사라지는 기준을 바꿨다.</b> 전에는 ValueText.Length == 0 이라 값이 정해지면 칩이 사라졌다.
그래서 잘못 고른 태그를 바꿀 때 도움이 끊겼다 — 태그를 고치는 일은 처음 고르는 일만큼 잦은데
고치는 쪽만 맨손이었다. 이제 기준은 '값이 있는지'가 아니라 <b>'타이핑 중인지'</b>다.
타이핑을 시작하면 아래 완성 목록이 같은 일을 더 정확하게 하므로 칩을 접는다.
그 옛 규칙을 고정하던 검사 하나를 새 규칙으로 고쳤다 — 의도한 변경이다.

완성 목록은 입력 중인 글자를 <b>인자로</b> 받는다. 태그 행의 Text 바인딩이
UpdateSourceTrigger=LostFocus 라 타이핑 중에는 ValueText 가 아직 옛 값이고,
그것으로 목록을 만들면 한 글자 뒤처진다.
순위는 이 타입에서 자주 쓰이는 순서 → 운영 사용 건수 → 이름이다.
알파벳 순으로 두면 상위 10종(전체 사용의 47.5%)이 목록 아래로 흩어진다.
검색은 TagSearch 를 그대로 쓴다 — 별칭·초성이 여기서도 듣는다('환자명'으로도 뜬다).

<b>Popup.IsOpen 은 기본이 TwoWay 다.</b> 읽기 전용 속성에 바인딩하면 XamlParseException 이고,
그 실패가 --dialog-shots 를 통째로 죽였다(종료코드 3). Mode=OneWay 를 명시한다.
게이트가 아니면 인스펙터를 띄우는 화면에서만 터졌을 것이다.

실측 14건: 값이 있어도 칩 유지 · 타이핑 전 미표시 · 타이핑 시 칩 접힘 · 별칭 적용 ·
자주 쓰는 것이 위 · 10줄 상한 · ↑↓ 순환 · Enter 확정과 커밋 · 확정 후 닫힘/칩 복귀 ·
정확히 하나면 안 가림 · Esc 가 값을 보존.

게이트: 테스트 291/291, --edit-smoke 0실패(신규 14), --dialog-shots 글자 2,465개 넘침 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-14 17:43:53 +09:00
co-authored by Claude Opus 5
parent a869e5a5bc
commit 9b2ae0ea67
5 changed files with 380 additions and 8 deletions
@@ -0,0 +1,110 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using SheetMe.Designer.ViewModels.Inspector;
namespace SheetMe.Designer.Behaviors;
/// <summary>
/// 태그 행 인라인 완성 — 타이핑하면 목록이 뜨고 ↑↓/Enter/Esc 로 고른다.
///
/// <b>왜 이 화면인가.</b> 태그를 붙이는 일은 하루 수십~수백 번이고 운영 사용이 8,032건이다.
/// 그런데 칩에 없는 태그는 이름을 16자 외워 치거나 1080×700 모달을 열어야 했고,
/// 자유 입력은 열려 있는데 제안이 없어서 <b>오타가 검증 없이 저장되고 EMR 에서 조용히 빈 칸이 됐다</b>.
///
/// <b>새 패턴을 만들지 않는다.</b> 이 앱에는 이미 같은 것이 있다 —
/// QueryEditorWindow 의 CompletionPopup(StaysOpen=True, ↑↓/Enter/Esc, 회귀 주석까지).
/// 거기서 배운 것을 그대로 쓴다: <b>Popup 을 StaysOpen=False 로 두면 마우스를 캡처해
/// 항목 클릭을 '바깥 클릭'으로 먹어 버린다</b>(그래서 눌러도 입력이 안 되고 사라졌다).
///
/// 부착 방식이 첨부 속성인 이유: 태그 행은 DataTemplate 안에 있어 x:Name 으로 잡을 수 없다.
/// </summary>
public static class TagCompletionBehavior
{
#region Member Fields
public static readonly DependencyProperty EnabledProperty =
DependencyProperty.RegisterAttached("Enabled", typeof(bool), typeof(TagCompletionBehavior),
new PropertyMetadata(false, OnEnabledChanged));
#endregion
#region Methods
public static void SetEnabled(DependencyObject target, bool value) => target.SetValue(EnabledProperty, value);
public static bool GetEnabled(DependencyObject target) => (bool)target.GetValue(EnabledProperty);
private static void OnEnabledChanged(DependencyObject target, DependencyPropertyChangedEventArgs e)
{
if (target is not TextBox box)
{
return;
}
if ((bool)e.NewValue)
{
box.TextChanged += OnTextChanged;
box.PreviewKeyDown += OnPreviewKeyDown;
box.LostKeyboardFocus += OnLostFocus;
return;
}
box.TextChanged -= OnTextChanged;
box.PreviewKeyDown -= OnPreviewKeyDown;
box.LostKeyboardFocus -= OnLostFocus;
}
private static TagPickerRowViewModel? RowOf(object sender)
=> (sender as FrameworkElement)?.DataContext as TagPickerRowViewModel;
private static void OnTextChanged(object sender, TextChangedEventArgs e)
{
if (RowOf(sender) is not { } row || sender is not TextBox box)
{
return;
}
// 코드가 값을 넣은 경우(칩 클릭·피커 확정)에는 목록을 열지 않는다 —
// 방금 고른 것 위에 목록이 덮이면 다 골랐는데 가리는 셈이다.
if (!box.IsKeyboardFocusWithin)
{
return;
}
row.BeginTyping();
row.RefreshCompletions(box.Text);
}
private static void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (RowOf(sender) is not { } row)
{
return;
}
switch (e.Key)
{
case Key.Down when row.ShowCompletions:
row.MoveCompletion(1);
e.Handled = true;
break;
case Key.Up when row.ShowCompletions:
row.MoveCompletion(-1);
e.Handled = true;
break;
case Key.Enter when row.ShowCompletions:
// 확정하면 Enter 를 여기서 먹는다 — 안 그러면 InspectorFieldBehavior 가
// 같은 Enter 로 옛 텍스트를 한 번 더 커밋한다.
e.Handled = row.CommitCompletion();
if (e.Handled && sender is TextBox committed)
{
committed.Text = row.ValueText;
committed.CaretIndex = committed.Text.Length;
}
break;
case Key.Escape when row.ShowCompletions:
row.EndTyping();
e.Handled = true;
break;
default:
break;
}
}
private static void OnLostFocus(object sender, KeyboardFocusChangedEventArgs e)
=> RowOf(sender)?.EndTyping();
#endregion
}