diff --git a/src/SheetMe.Designer/Behaviors/TagCompletionBehavior.cs b/src/SheetMe.Designer/Behaviors/TagCompletionBehavior.cs
new file mode 100644
index 0000000..67bbb6f
--- /dev/null
+++ b/src/SheetMe.Designer/Behaviors/TagCompletionBehavior.cs
@@ -0,0 +1,110 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using SheetMe.Designer.ViewModels.Inspector;
+
+namespace SheetMe.Designer.Behaviors;
+
+///
+/// 태그 행 인라인 완성 — 타이핑하면 목록이 뜨고 ↑↓/Enter/Esc 로 고른다.
+///
+/// 왜 이 화면인가. 태그를 붙이는 일은 하루 수십~수백 번이고 운영 사용이 8,032건이다.
+/// 그런데 칩에 없는 태그는 이름을 16자 외워 치거나 1080×700 모달을 열어야 했고,
+/// 자유 입력은 열려 있는데 제안이 없어서 오타가 검증 없이 저장되고 EMR 에서 조용히 빈 칸이 됐다.
+///
+/// 새 패턴을 만들지 않는다. 이 앱에는 이미 같은 것이 있다 —
+/// QueryEditorWindow 의 CompletionPopup(StaysOpen=True, ↑↓/Enter/Esc, 회귀 주석까지).
+/// 거기서 배운 것을 그대로 쓴다: Popup 을 StaysOpen=False 로 두면 마우스를 캡처해
+/// 항목 클릭을 '바깥 클릭'으로 먹어 버린다(그래서 눌러도 입력이 안 되고 사라졌다).
+///
+/// 부착 방식이 첨부 속성인 이유: 태그 행은 DataTemplate 안에 있어 x:Name 으로 잡을 수 없다.
+///
+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
+}
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index 1aa36f7..3367716 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -151,6 +151,67 @@ public static class EditSmoke
Check("Enter: 글자면 편집을 시작한다", asked == 1, $"요청 {asked}회");
}
+ // 태그 행 인라인 완성 — 이 행에서 모달 없이 태그를 고르는 주 경로.
+ {
+ var tagRow = new ViewModels.Inspector.TagPickerRowViewModel(
+ "데이터 태그", "태그 선택",
+ SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags,
+ SheetMe.Core.Catalog.LegacyTagUsageCatalog.SuggestFor("DataInterfaceTag", "TextBox"));
+ var committed = new List();
+ tagRow.Commit = value => committed.Add(value);
+
+ Check("완성: 값이 있어도 칩이 남는다", tagRow.ShowSuggestions,
+ "잘못 고른 태그를 바꿀 때 도움이 끊기지 않아야 한다");
+
+ // 타이핑 전에는 열리지 않는다
+ tagRow.RefreshCompletions("환자");
+ Check("완성: 타이핑 전에는 안 뜬다", !tagRow.ShowCompletions);
+
+ tagRow.BeginTyping();
+ Check("완성: 타이핑하면 칩이 접힌다", !tagRow.ShowSuggestions);
+
+ // 별칭이 여기서도 들어야 한다 — 카탈로그 표기는 '이름' 이다
+ tagRow.RefreshCompletions("환자명");
+ Check("완성: 별칭이 인스펙터에서도 듣는다", tagRow.ShowCompletions && tagRow.Completions.Count > 0,
+ $"목록 {tagRow.Completions.Count}개");
+
+ // 순위 — 이 타입에서 자주 쓰이는 것이 위로
+ tagRow.RefreshCompletions("PAT_");
+ var top = tagRow.Completions.FirstOrDefault();
+ Check("완성: 자주 쓰는 것이 위에", top == "PAT_이름", $"1번째 {top}");
+ Check("완성: 10줄을 넘기지 않는다", tagRow.Completions.Count <= 10,
+ $"{tagRow.Completions.Count}줄");
+
+ // ↑↓ 순환
+ tagRow.MoveCompletion(-1);
+ Check("완성: ↑ 로 끝으로 돈다", tagRow.CompletionIndex == tagRow.Completions.Count - 1,
+ $"index {tagRow.CompletionIndex}");
+ tagRow.MoveCompletion(1);
+ Check("완성: ↓ 로 처음으로 돈다", tagRow.CompletionIndex == 0);
+
+ // Enter 확정 — 값이 되고 목록이 닫히고 칩이 돌아온다
+ var picked = tagRow.Completions[0];
+ Check("완성: Enter 로 확정된다", tagRow.CommitCompletion());
+ Check("완성: 확정한 값이 커밋된다", tagRow.ValueText == picked && committed.Contains(picked),
+ $"값 {tagRow.ValueText}, 커밋 {committed.Count}회");
+ Check("완성: 확정하면 목록이 닫힌다", !tagRow.ShowCompletions);
+ Check("완성: 확정하면 칩이 돌아온다", tagRow.ShowSuggestions);
+
+ // 정확히 그것만 남으면 목록을 띄우지 않는다 — 다 골랐는데 가리는 셈이다
+ tagRow.BeginTyping();
+ tagRow.RefreshCompletions("PAT_이름");
+ var exact = tagRow.Completions.Count == 1 && tagRow.Completions[0] == "PAT_이름";
+ Check("완성: 정확히 하나 남으면 안 가린다", !exact || !tagRow.ShowCompletions,
+ $"목록 {tagRow.Completions.Count}개, 열림 {tagRow.ShowCompletions}");
+
+ // Esc 는 값을 바꾸지 않는다
+ var before = tagRow.ValueText;
+ tagRow.BeginTyping();
+ tagRow.RefreshCompletions("ETC_");
+ tagRow.EndTyping();
+ Check("완성: Esc 는 값을 바꾸지 않는다", tagRow.ValueText == before && !tagRow.ShowCompletions);
+ }
+
// 미리보기가 인쇄 필터를 실제로 쓰는가.
// PrintFilter 단위 테스트는 판정이 옳다는 것만 말한다 — 렌더 경로가 그 판정을 부르는지는
// 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 여러 번 있었다.
@@ -1089,12 +1150,20 @@ public static class EditSmoke
}
}
- // 값이 정해지면 칩을 감춘다 — 좁은 인스펙터가 길어지지 않도록
+ // 칩을 감추는 기준이 바뀌었다.
+ //
+ // 전에는 "값이 정해지면 감춘다"였고 이 검사가 그것을 고정하고 있었다.
+ // 그 규칙 때문에 잘못 고른 태그를 바꿀 때 도움이 끊겼다 —
+ // 태그를 고치는 일은 처음 고르는 일만큼 잦은데(운영 8,032건) 고치는 쪽만 맨손이었다.
+ // 이제 기준은 '값이 있는지'가 아니라 '타이핑 중인지'다.
+ // 타이핑을 시작하면 아래 완성 목록이 같은 일을 더 정확하게 하므로 칩을 접는다.
var tagRow = new ViewModels.Inspector.TagPickerRowViewModel("데이터 태그", "제목",
SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags, pictureSuggest);
Check("태그 제안: 값이 비면 칩을 보여 준다", tagRow.ShowSuggestions);
tagRow.ValueText = "PAT_이름";
- Check("태그 제안: 값이 정해지면 칩을 감춘다", !tagRow.ShowSuggestions);
+ Check("태그 제안: 값이 있어도 칩이 남는다", tagRow.ShowSuggestions);
+ tagRow.BeginTyping();
+ Check("태그 제안: 타이핑 중에는 칩을 접는다", !tagRow.ShowSuggestions);
}
// 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
index 76edf4a..a977766 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
@@ -433,11 +433,134 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
public IReadOnlyList Suggestions { get; }
///
- /// 제안을 보여 줄지 — 값이 비어 있을 때만.
- /// 이미 정해진 행 아래에도 칩을 깔면 좁은 인스펙터가 금방 길어진다.
- /// 제안이 필요한 순간은 '아직 안 골랐을 때'다.
+ /// 제안을 보여 줄지.
+ ///
+ /// 전에는 ValueText.Length == 0 이었다 — 값이 정해지면 칩이 사라졌다.
+ /// 그래서 잘못 고른 태그를 바꿀 때 도움이 끊겼다. 실사용에서 태그를 고치는 일은
+ /// 처음 고르는 일만큼 잦은데(운영 태그 사용 8,032건), 고치는 쪽만 맨손이었다.
+ /// 이제 값이 있어도 남긴다. 대신 타이핑을 시작하면 접는다 —
+ /// 그때부터는 아래 완성 목록이 같은 일을 더 정확하게 한다.
///
- public bool ShowSuggestions => ValueText.Length == 0 && Suggestions.Count > 0;
+ public bool ShowSuggestions => !IsTyping && Suggestions.Count > 0;
+
+ ///
+ /// 인라인 완성 목록 — 이 행에서 모달 없이 태그를 고르는 주 경로.
+ ///
+ /// 왜 여기가 주 경로인가. 칩 상위 8종만으로 운영 사용의 41.7%, 상위 16종으로 61.5% 를 덮는다.
+ /// 나머지는 이름을 정확히 16자 외워 치거나 1080×700 모달을 열어야 했고,
+ /// 자유 입력은 열려 있는데 제안이 없어서 오타가 검증 없이 저장되고
+ /// EMR 에서 조용히 빈 칸이 됐다.
+ /// 같은 인스펙터의 '속성 추가' 행에는 이미 부분일치 제안이 있다 — 패턴은 있는데
+ /// 운영 8,032건이 걸린 칸에만 없었다.
+ ///
+ public System.Collections.ObjectModel.ObservableCollection Completions { get; } = new();
+
+ /// 완성 목록에서 지금 짚고 있는 줄 — ↑↓ 로 움직이고 Enter 로 확정한다
+ public int CompletionIndex
+ {
+ get => completionIndex;
+ set => SetProperty(ref completionIndex, value);
+ }
+
+ public bool ShowCompletions
+ {
+ get => showCompletions;
+ private set => SetProperty(ref showCompletions, value);
+ }
+
+ /// 타이핑 중인가 — 칩을 접고 완성을 여는 기준
+ private bool IsTyping { get; set; }
+
+ private int completionIndex = -1;
+ private bool showCompletions;
+
+ ///
+ /// 지금 입력된 글자로 완성 목록을 다시 만든다.
+ ///
+ /// 순위는 이 타입에서 자주 쓰이는 순서 → 운영 사용 건수 → 이름이다.
+ /// 알파벳 순으로 두면 상위 10종(전체 사용의 47.5%)이 목록 아래로 흩어진다.
+ /// 검색은 를 그대로 쓴다 — 별칭·초성이 여기서도 듣는다.
+ ///
+ public void RefreshCompletions(string typed)
+ {
+ // 인자로 받는다 — 태그 행의 Text 바인딩은 UpdateSourceTrigger=LostFocus 라
+ // 타이핑 중에는 ValueText 가 아직 옛 값이다. 그것으로 목록을 만들면 한 글자 뒤처진다.
+ var query = typed.Trim();
+ Completions.Clear();
+ if (!IsTyping || query.Length == 0)
+ {
+ ShowCompletions = false;
+ CompletionIndex = -1;
+ return;
+ }
+ var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var ranked = Choices
+ .Where(tag => Core.Catalog.TagSearch.MatchesAll(tag, tokens))
+ .OrderBy(tag =>
+ {
+ var rank = preferredAll.ToList().IndexOf(tag);
+ return rank < 0 ? int.MaxValue : rank;
+ })
+ .ThenByDescending(Core.Catalog.LegacyTagUsageCounts.Of)
+ .ThenBy(tag => tag, StringComparer.Ordinal)
+ .Take(CompletionLimit)
+ .ToList();
+ foreach (var tag in ranked)
+ {
+ Completions.Add(tag);
+ }
+ // 이미 정확히 그 태그만 남았으면 목록을 띄우지 않는다 — 다 골랐는데 가리는 셈이다
+ ShowCompletions = ranked.Count > 0
+ && !(ranked.Count == 1 && string.Equals(ranked[0], query, StringComparison.Ordinal));
+ CompletionIndex = ShowCompletions ? 0 : -1;
+ }
+
+ /// 사용자가 글자를 넣기 시작했다 — 칩을 접고 완성을 연다
+ public void BeginTyping()
+ {
+ if (IsTyping)
+ {
+ return;
+ }
+ IsTyping = true;
+ OnPropertyChanged(nameof(ShowSuggestions));
+ }
+
+ /// 편집이 끝났다(확정·취소·포커스 이동) — 칩을 되돌리고 완성을 닫는다
+ public void EndTyping()
+ {
+ IsTyping = false;
+ ShowCompletions = false;
+ CompletionIndex = -1;
+ Completions.Clear();
+ OnPropertyChanged(nameof(ShowSuggestions));
+ }
+
+ /// ↑↓ — 목록을 벗어나지 않고 순환한다
+ public void MoveCompletion(int delta)
+ {
+ if (Completions.Count == 0)
+ {
+ return;
+ }
+ CompletionIndex = ((CompletionIndex + delta) % Completions.Count + Completions.Count) % Completions.Count;
+ }
+
+ /// 짚고 있는 줄을 값으로 확정한다 — 확정했으면 true
+ public bool CommitCompletion()
+ {
+ if (!ShowCompletions || CompletionIndex < 0 || CompletionIndex >= Completions.Count)
+ {
+ return false;
+ }
+ var picked = Completions[CompletionIndex];
+ EndTyping();
+ ValueText = picked;
+ return true;
+ }
+
+ /// 완성 목록에 깔 줄 수 — 10행×24px 이면 인스펙터를 덮지 않는다
+ private const int CompletionLimit = 10;
/// 찾아보기 — 검색 대화상자 열기
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml
index 28bb921..2ae79a7 100644
--- a/src/SheetMe.Designer/Views/InspectorView.xaml
+++ b/src/SheetMe.Designer/Views/InspectorView.xaml
@@ -548,9 +548,41 @@
Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택"
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+