Files
SheetMe/src/SheetMe.Designer/Views/InspectorView.xaml.cs
T
MsystechandClaude Opus 5 9b2ae0ea67 태그 행에서 모달 없이 태그를 고른다 — 인라인 완성
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>
2026-08-14 17:43:53 +09:00

195 lines
6.7 KiB
C#

using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using SheetMe.Designer.ViewModels.Inspector;
namespace SheetMe.Designer.Views;
/// <summary>
/// 속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.
///
/// <b>선택이 바뀌어도 손이 이어지게 한다.</b> 선택 집합이 바뀌면 뷰모델이 행을 통째로 다시 만들어서,
/// 그대로 두면 편집하던 칸과 스크롤 위치가 매번 사라진다. 하루 수백 건을 다루는 사람에게 그건
/// "다음 컨트롤로 넘어가면 손이 처음으로 돌아간다"는 뜻이다 —
/// 라벨 20개의 글꼴을 차례로 고치는 일이 20번의 스크롤·클릭이 된다.
///
/// 행을 키 기준으로 갱신하는 쪽이 더 근본적이지만 이 패널은 회귀 기록이 가장 많은 곳이라
/// 이번에는 재구성을 그대로 두고 <b>포커스와 스크롤만 되돌린다</b>.
/// 행을 라벨로 찾는다 — 타입이 달라도 같은 이름의 행(글꼴·정렬·표시)은 같은 일을 한다.
/// </summary>
public partial class InspectorView : UserControl
{
#region Member Fields
/// <summary>재구성 직전에 포커스가 있던 행의 라벨</summary>
private string? focusedLabel;
private double savedOffset;
#endregion
#region Constructors
public InspectorView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
#endregion
#region Methods
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is InspectorViewModel old)
{
old.Rebuilding -= OnRebuilding;
old.Rebuilt -= OnRebuilt;
}
if (e.NewValue is InspectorViewModel fresh)
{
fresh.Rebuilding += OnRebuilding;
fresh.Rebuilt += OnRebuilt;
}
}
private void OnRebuilding()
{
savedOffset = RowScroll.VerticalOffset;
focusedLabel = null;
if (Keyboard.FocusedElement is DependencyObject focused && IsDescendantOfRows(focused))
{
focusedLabel = RowLabelOf(focused);
}
}
private void OnRebuilt()
{
// 컨테이너가 아직 없다 — 레이아웃이 한 번 돌아야 행이 생긴다.
// Loaded 나 즉시 호출로는 늘 빈 트리를 뒤지게 된다(이 저장소에서 같은 함정을 두 번 밟았다).
Dispatcher.BeginInvoke(new Action(Restore), System.Windows.Threading.DispatcherPriority.Loaded);
}
private void Restore()
{
RowScroll.ScrollToVerticalOffset(savedOffset);
if (focusedLabel is null)
{
return;
}
var target = FindRowByLabel(focusedLabel);
focusedLabel = null;
// 포커스를 강제로 옮기지 않는다 — 사용자가 그 사이에 다른 곳을 눌렀으면 그쪽이 옳다
if (target is not null && Keyboard.FocusedElement is not TextBoxBase)
{
target.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
}
}
/// <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>
public void FocusFirstRow()
{
if (RowHost.Items.Count == 0)
{
return;
}
RowHost.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
}
private bool IsDescendantOfRows(DependencyObject node)
{
for (var i = 0; i < 40; i++)
{
if (ReferenceEquals(node, RowHost))
{
return true;
}
var parent = VisualParentOf(node);
if (parent is null)
{
return false;
}
node = parent;
}
return false;
}
/// <summary>이 요소가 속한 행의 라벨 — 행 VM 을 DataContext 로 갖는 조상을 찾는다</summary>
private static string? RowLabelOf(DependencyObject node)
{
for (var i = 0; i < 40; i++)
{
if (node is FrameworkElement { DataContext: PropertyRowViewModel row })
{
return row.Label;
}
var parent = VisualParentOf(node);
if (parent is null)
{
return null;
}
node = parent;
}
return null;
}
private FrameworkElement? FindRowByLabel(string label)
{
foreach (var item in RowHost.Items)
{
if (item is not PropertyRowViewModel row || !string.Equals(row.Label, label, StringComparison.Ordinal))
{
continue;
}
if (RowHost.ItemContainerGenerator.ContainerFromItem(row) is FrameworkElement container)
{
return container;
}
}
return null;
}
/// <summary>ContentPresenter 는 시각 부모만 갖는 경우가 있어 논리 부모로도 올라간다</summary>
private static DependencyObject? VisualParentOf(DependencyObject node)
=> node is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(node)
: LogicalTreeHelper.GetParent(node);
#endregion
}