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; /// /// 속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더. /// /// 선택이 바뀌어도 손이 이어지게 한다. 선택 집합이 바뀌면 뷰모델이 행을 통째로 다시 만들어서, /// 그대로 두면 편집하던 칸과 스크롤 위치가 매번 사라진다. 하루 수백 건을 다루는 사람에게 그건 /// "다음 컨트롤로 넘어가면 손이 처음으로 돌아간다"는 뜻이다 — /// 라벨 20개의 글꼴을 차례로 고치는 일이 20번의 스크롤·클릭이 된다. /// /// 행을 키 기준으로 갱신하는 쪽이 더 근본적이지만 이 패널은 회귀 기록이 가장 많은 곳이라 /// 이번에는 재구성을 그대로 두고 포커스와 스크롤만 되돌린다. /// 행을 라벨로 찾는다 — 타입이 달라도 같은 이름의 행(글꼴·정렬·표시)은 같은 일을 한다. /// public partial class InspectorView : UserControl { #region Member Fields /// 재구성 직전에 포커스가 있던 행의 라벨 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)); } } /// /// 완성 목록의 한 줄을 눌렀다. /// /// 팝업은 StaysOpen=True 다 — False 로 두면 팝업이 마우스를 캡처해 이 클릭을 /// '바깥 클릭'으로 먹어 버린다(눌러도 입력이 안 되고 사라진다). /// 그래서 클릭을 직접 받아 값으로 확정하고 코드로 닫는다. /// 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; } /// 이 요소가 속한 행 VM — 팝업 안이라 시각 트리가 본체와 끊겨 논리 부모로도 올라간다 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; } /// Ctrl+1/2/3 이나 F4 로 인스펙터에 들어올 때 첫 편집 칸을 잡아 준다 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; } /// 이 요소가 속한 행의 라벨 — 행 VM 을 DataContext 로 갖는 조상을 찾는다 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; } /// ContentPresenter 는 시각 부모만 갖는 경우가 있어 논리 부모로도 올라간다 private static DependencyObject? VisualParentOf(DependencyObject node) => node is Visual or System.Windows.Media.Media3D.Visual3D ? VisualTreeHelper.GetParent(node) : LogicalTreeHelper.GetParent(node); #endregion }