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}"/> - + + + + + + + + + + + + + + +