태그 행에서 모달 없이 태그를 고른다 — 인라인 완성
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:
co-authored by
Claude Opus 5
parent
a869e5a5bc
commit
9b2ae0ea67
@@ -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
|
||||||
|
}
|
||||||
@@ -151,6 +151,67 @@ public static class EditSmoke
|
|||||||
Check("Enter: 글자면 편집을 시작한다", asked == 1, $"요청 {asked}회");
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// 미리보기가 인쇄 필터를 <b>실제로 쓰는가</b>.
|
// 미리보기가 인쇄 필터를 <b>실제로 쓰는가</b>.
|
||||||
// PrintFilter 단위 테스트는 판정이 옳다는 것만 말한다 — 렌더 경로가 그 판정을 부르는지는
|
// PrintFilter 단위 테스트는 판정이 옳다는 것만 말한다 — 렌더 경로가 그 판정을 부르는지는
|
||||||
// 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 여러 번 있었다.
|
// 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 여러 번 있었다.
|
||||||
@@ -1089,12 +1150,20 @@ public static class EditSmoke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 값이 정해지면 칩을 감춘다 — 좁은 인스펙터가 길어지지 않도록
|
// 칩을 감추는 기준이 바뀌었다.
|
||||||
|
//
|
||||||
|
// 전에는 "값이 정해지면 감춘다"였고 이 검사가 그것을 고정하고 있었다.
|
||||||
|
// 그 규칙 때문에 <b>잘못 고른 태그를 바꿀 때 도움이 끊겼다</b> —
|
||||||
|
// 태그를 고치는 일은 처음 고르는 일만큼 잦은데(운영 8,032건) 고치는 쪽만 맨손이었다.
|
||||||
|
// 이제 기준은 '값이 있는지'가 아니라 <b>'타이핑 중인지'</b>다.
|
||||||
|
// 타이핑을 시작하면 아래 완성 목록이 같은 일을 더 정확하게 하므로 칩을 접는다.
|
||||||
var tagRow = new ViewModels.Inspector.TagPickerRowViewModel("데이터 태그", "제목",
|
var tagRow = new ViewModels.Inspector.TagPickerRowViewModel("데이터 태그", "제목",
|
||||||
SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags, pictureSuggest);
|
SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags, pictureSuggest);
|
||||||
Check("태그 제안: 값이 비면 칩을 보여 준다", tagRow.ShowSuggestions);
|
Check("태그 제안: 값이 비면 칩을 보여 준다", tagRow.ShowSuggestions);
|
||||||
tagRow.ValueText = "PAT_이름";
|
tagRow.ValueText = "PAT_이름";
|
||||||
Check("태그 제안: 값이 정해지면 칩을 감춘다", !tagRow.ShowSuggestions);
|
Check("태그 제안: 값이 있어도 칩이 남는다", tagRow.ShowSuggestions);
|
||||||
|
tagRow.BeginTyping();
|
||||||
|
Check("태그 제안: 타이핑 중에는 칩을 접는다", !tagRow.ShowSuggestions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다
|
// 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다
|
||||||
|
|||||||
@@ -433,11 +433,134 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
|
|||||||
public IReadOnlyList<string> Suggestions { get; }
|
public IReadOnlyList<string> Suggestions { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 제안을 보여 줄지 — <b>값이 비어 있을 때만</b>.
|
/// 제안을 보여 줄지.
|
||||||
/// 이미 정해진 행 아래에도 칩을 깔면 좁은 인스펙터가 금방 길어진다.
|
///
|
||||||
/// 제안이 필요한 순간은 '아직 안 골랐을 때'다.
|
/// 전에는 <c>ValueText.Length == 0</c> 이었다 — 값이 정해지면 칩이 사라졌다.
|
||||||
|
/// 그래서 <b>잘못 고른 태그를 바꿀 때 도움이 끊겼다</b>. 실사용에서 태그를 고치는 일은
|
||||||
|
/// 처음 고르는 일만큼 잦은데(운영 태그 사용 8,032건), 고치는 쪽만 맨손이었다.
|
||||||
|
/// 이제 값이 있어도 남긴다. 대신 <b>타이핑을 시작하면 접는다</b> —
|
||||||
|
/// 그때부터는 아래 완성 목록이 같은 일을 더 정확하게 한다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ShowSuggestions => ValueText.Length == 0 && Suggestions.Count > 0;
|
public bool ShowSuggestions => !IsTyping && Suggestions.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 인라인 완성 목록 — 이 행에서 모달 없이 태그를 고르는 주 경로.
|
||||||
|
///
|
||||||
|
/// <b>왜 여기가 주 경로인가.</b> 칩 상위 8종만으로 운영 사용의 41.7%, 상위 16종으로 61.5% 를 덮는다.
|
||||||
|
/// 나머지는 이름을 정확히 16자 외워 치거나 1080×700 모달을 열어야 했고,
|
||||||
|
/// 자유 입력은 열려 있는데 제안이 없어서 <b>오타가 검증 없이 저장되고
|
||||||
|
/// EMR 에서 조용히 빈 칸이 됐다</b>.
|
||||||
|
/// 같은 인스펙터의 '속성 추가' 행에는 이미 부분일치 제안이 있다 — 패턴은 있는데
|
||||||
|
/// 운영 8,032건이 걸린 칸에만 없었다.
|
||||||
|
/// </summary>
|
||||||
|
public System.Collections.ObjectModel.ObservableCollection<string> Completions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>완성 목록에서 지금 짚고 있는 줄 — ↑↓ 로 움직이고 Enter 로 확정한다</summary>
|
||||||
|
public int CompletionIndex
|
||||||
|
{
|
||||||
|
get => completionIndex;
|
||||||
|
set => SetProperty(ref completionIndex, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShowCompletions
|
||||||
|
{
|
||||||
|
get => showCompletions;
|
||||||
|
private set => SetProperty(ref showCompletions, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>타이핑 중인가 — 칩을 접고 완성을 여는 기준</summary>
|
||||||
|
private bool IsTyping { get; set; }
|
||||||
|
|
||||||
|
private int completionIndex = -1;
|
||||||
|
private bool showCompletions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 지금 입력된 글자로 완성 목록을 다시 만든다.
|
||||||
|
///
|
||||||
|
/// 순위는 <b>이 타입에서 자주 쓰이는 순서 → 운영 사용 건수 → 이름</b>이다.
|
||||||
|
/// 알파벳 순으로 두면 상위 10종(전체 사용의 47.5%)이 목록 아래로 흩어진다.
|
||||||
|
/// 검색은 <see cref="Core.Catalog.TagSearch"/> 를 그대로 쓴다 — 별칭·초성이 여기서도 듣는다.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>사용자가 글자를 넣기 시작했다 — 칩을 접고 완성을 연다</summary>
|
||||||
|
public void BeginTyping()
|
||||||
|
{
|
||||||
|
if (IsTyping)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
IsTyping = true;
|
||||||
|
OnPropertyChanged(nameof(ShowSuggestions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>편집이 끝났다(확정·취소·포커스 이동) — 칩을 되돌리고 완성을 닫는다</summary>
|
||||||
|
public void EndTyping()
|
||||||
|
{
|
||||||
|
IsTyping = false;
|
||||||
|
ShowCompletions = false;
|
||||||
|
CompletionIndex = -1;
|
||||||
|
Completions.Clear();
|
||||||
|
OnPropertyChanged(nameof(ShowSuggestions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>↑↓ — 목록을 벗어나지 않고 순환한다</summary>
|
||||||
|
public void MoveCompletion(int delta)
|
||||||
|
{
|
||||||
|
if (Completions.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CompletionIndex = ((CompletionIndex + delta) % Completions.Count + Completions.Count) % Completions.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>짚고 있는 줄을 값으로 확정한다 — 확정했으면 true</summary>
|
||||||
|
public bool CommitCompletion()
|
||||||
|
{
|
||||||
|
if (!ShowCompletions || CompletionIndex < 0 || CompletionIndex >= Completions.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var picked = Completions[CompletionIndex];
|
||||||
|
EndTyping();
|
||||||
|
ValueText = picked;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>완성 목록에 깔 줄 수 — 10행×24px 이면 인스펙터를 덮지 않는다</summary>
|
||||||
|
private const int CompletionLimit = 10;
|
||||||
|
|
||||||
/// <summary>찾아보기 — 검색 대화상자 열기</summary>
|
/// <summary>찾아보기 — 검색 대화상자 열기</summary>
|
||||||
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
|
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
|
||||||
|
|||||||
@@ -548,9 +548,41 @@
|
|||||||
Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택"
|
Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택"
|
||||||
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
|
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextBox Style="{StaticResource RowInput}" bh:InspectorFieldBehavior.CommitOnEnter="True" Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
|
<TextBox x:Name="TagInput" Style="{StaticResource RowInput}"
|
||||||
ToolTip="직접 입력하거나 아래 제안을 누르세요"/>
|
bh:InspectorFieldBehavior.CommitOnEnter="True"
|
||||||
|
bh:TagCompletionBehavior.Enabled="True"
|
||||||
|
Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
|
||||||
|
ToolTip="치면 목록이 뜹니다 — ↑↓ 이동, Enter 확정, Esc 취소"/>
|
||||||
<TextBlock Style="{StaticResource MixedHintOverlay}"/>
|
<TextBlock Style="{StaticResource MixedHintOverlay}"/>
|
||||||
|
<!--
|
||||||
|
인라인 완성 — 이 행에서 모달 없이 태그를 고르는 주 경로.
|
||||||
|
StaysOpen=True 가 필수다: False 면 팝업이 마우스를 캡처해 항목 클릭을
|
||||||
|
'바깥 클릭'으로 먹어 버린다(눌러도 입력이 안 되고 사라진다 —
|
||||||
|
QueryEditorWindow 의 CompletionPopup 에서 같은 것을 이미 겪었다).
|
||||||
|
AllowsTransparency 를 켜지 않는다 — 레이어드 창이 되면 ClearType 이 꺼져
|
||||||
|
13px 한글이 뭉개진다(진단 cleartype 의 D 표본).
|
||||||
|
-->
|
||||||
|
<Popup IsOpen="{Binding ShowCompletions, Mode=OneWay}" PlacementTarget="{Binding ElementName=TagInput}"
|
||||||
|
Placement="Bottom" StaysOpen="True" Focusable="False">
|
||||||
|
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}"
|
||||||
|
BorderThickness="1" CornerRadius="6" Padding="2"
|
||||||
|
MinWidth="{Binding ActualWidth, ElementName=TagInput}"
|
||||||
|
RenderOptions.ClearTypeHint="Enabled">
|
||||||
|
<ItemsControl ItemsSource="{Binding Completions}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Height="24" Padding="7,0" CornerRadius="4"
|
||||||
|
Background="Transparent"
|
||||||
|
MouseLeftButtonDown="OnCompletionClicked">
|
||||||
|
<TextBlock Text="{Binding}" FontFamily="Consolas" FontSize="11.5"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource B.Ink}"/>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
<!-- 이 컨트롤 타입에서 실제로 쓰이는 태그(빈도순). 아직 안 골랐을 때만 뜬다 —
|
<!-- 이 컨트롤 타입에서 실제로 쓰이는 태그(빈도순). 아직 안 골랐을 때만 뜬다 —
|
||||||
|
|||||||
@@ -84,6 +84,44 @@ public partial class InspectorView : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 완성 목록의 한 줄을 눌렀다.
|
||||||
|
///
|
||||||
|
/// 팝업은 StaysOpen=True 다 — False 로 두면 팝업이 마우스를 캡처해 이 클릭을
|
||||||
|
/// '바깥 클릭'으로 먹어 버린다(눌러도 입력이 안 되고 사라진다).
|
||||||
|
/// 그래서 클릭을 직접 받아 값으로 확정하고 코드로 닫는다.
|
||||||
|
/// </summary>
|
||||||
|
private void OnCompletionClicked(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not FrameworkElement { DataContext: string picked }
|
||||||
|
|| picked.Length == 0
|
||||||
|
|| FindRowOwner(sender) is not PropertyRowViewModel row)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row is ViewModels.Inspector.TagPickerRowViewModel tagRow)
|
||||||
|
{
|
||||||
|
tagRow.EndTyping();
|
||||||
|
}
|
||||||
|
row.ValueText = picked;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>이 요소가 속한 행 VM — 팝업 안이라 시각 트리가 본체와 끊겨 논리 부모로도 올라간다</summary>
|
||||||
|
private static PropertyRowViewModel? FindRowOwner(object node)
|
||||||
|
{
|
||||||
|
var current = node as DependencyObject;
|
||||||
|
for (var i = 0; i < 40 && current is not null; i++)
|
||||||
|
{
|
||||||
|
if (current is FrameworkElement { DataContext: PropertyRowViewModel row })
|
||||||
|
{
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
current = VisualParentOf(current);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Ctrl+1/2/3 이나 F4 로 인스펙터에 들어올 때 첫 편집 칸을 잡아 준다</summary>
|
/// <summary>Ctrl+1/2/3 이나 F4 로 인스펙터에 들어올 때 첫 편집 칸을 잡아 준다</summary>
|
||||||
public void FocusFirstRow()
|
public void FocusFirstRow()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user