using System.Windows.Media; using SheetMe.Designer.ViewModels.Controls; namespace SheetMe.Designer.ViewModels.Inspector; /// /// 인스펙터 행 ViewModel — 라벨 + 문자열 정규화 값. 커밋 시(값 변경 시에만) 소유자 콜백으로 /// Undo 스냅샷 → 전체 선택 대상 적용 → 시각 재해석이 수행된다. /// public abstract class PropertyRowViewModel : ViewModelBase { #region Member Fields private string valueText = string.Empty; private bool building; #endregion #region Properties /// 행 라벨(한글) public string Label { get; } /// 선택 대상들의 값이 서로 다른지 — "여러 값" 표시 public bool IsMixed { get; private set; } /// /// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용. /// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼 /// 실제보다 적게 나온다. 사용자가 세는 것은 줄이 아니라 속성이다. /// public virtual int LeafCount => 1; /// 소속 섹션 — null 이면 섹션 밖(항상 표시) public SectionRowViewModel? Section { get; set; } /// 접힌 섹션에 속하면 숨긴다 — ItemContainerStyle 이 바인딩 public bool IsRowVisible => Section is null || Section.IsExpanded; /// 표시 여부 통지(섹션 접기/펴기) public void NotifyVisibilityChanged() => OnPropertyChanged(nameof(IsRowVisible)); /// 정규화 문자열 값 — 파생 편집기가 형 변환 public string ValueText { get => valueText; set { if (!SetProperty(ref valueText, value) || building) { return; } IsMixed = false; OnPropertyChanged(nameof(IsMixed)); Commit?.Invoke(value); OnValueApplied(); } } /// 커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용) public Action? Commit { get; set; } #endregion #region Constructors protected PropertyRowViewModel(string label) { Label = label; } #endregion #region Methods /// 초기값 세팅(커밋 미발생) public void Initialize(string? value, bool isMixed = false) { building = true; valueText = value ?? string.Empty; IsMixed = isMixed; OnPropertyChanged(nameof(ValueText)); OnPropertyChanged(nameof(IsMixed)); building = false; // 파생 표시(토글 3상태·미리보기·요약)도 초기값 기준으로 맞춘다. // 드래그 중 RefreshBoundsRows 가 Initialize 만 호출하는 경로가 있어 여기서 하지 않으면 파생이 뒤처진다. OnValueApplied(); } /// 값 적용 후 파생 갱신 지점 protected virtual void OnValueApplied() { } #endregion } /// /// 구분 헤더 행 — 클릭으로 접기/펴기. /// /// Rows 는 평면 컬렉션을 유지하고(다중선택 재구성이 잦아 트리 재구축 비용을 피한다) 섹션이 자기 /// 소속 행을 들고 있다가 IsRowVisible 만 갱신한다. 컨테이너 Visibility 만 바뀌므로 편집 중이던 /// 값·포커스 상태가 보존된다. /// public sealed class SectionRowViewModel : PropertyRowViewModel { private bool isExpanded = true; /// 이 섹션에 속한 행들 public List Children { get; } = new(); /// 펼침 상태 public bool IsExpanded { get => isExpanded; set { if (!SetProperty(ref isExpanded, value)) { return; } OnPropertyChanged(nameof(ChevronIcon)); foreach (var child in Children) { child.NotifyVisibilityChanged(); } } } /// 펼침 표시 아이콘 public string ChevronIcon => isExpanded ? "chevron-down" : "chevron-right"; /// 접힌 상태에서 몇 개가 숨어 있는지 public string CountText { get { var total = Children.Sum(c => c.LeafCount); return total == 0 ? string.Empty : total.ToString(); } } /// 헤더 클릭 — 접기/펴기 public M.Framework.WPF.ICustomCommand? ToggleCommand { get; } public SectionRowViewModel(string label) : base(label) { ToggleCommand = new M.Framework.WPF.Command((sender, e) => IsExpanded = !IsExpanded); } /// 행 편입 — InspectorViewModel 이 Rows.Add 와 함께 호출 public void Adopt(PropertyRowViewModel row) { row.Section = this; Children.Add(row); OnPropertyChanged(nameof(CountText)); } } /// 한 줄 문자열 행 public sealed class TextRowViewModel : PropertyRowViewModel { public TextRowViewModel(string label) : base(label) { } } /// /// 두 값을 한 줄에 나란히 두는 행 — X|Y, 너비|높이. /// /// 자식 행은 평범한 행 그대로다. 값·커밋·"여러 값" 판정이 전부 자식 안에서 끝나므로 /// 커밋 규약(Initialize 는 Commit 을 내지 않고, 값이 바뀔 때만 Undo 스냅샷 1회)이 그대로 유지된다. /// 한쪽만 값이 갈리는 다중선택도 자연히 옳게 나온다. /// 이 행 자신은 컨테이너일 뿐이라 Commit 을 갖지 않는다. /// public sealed class PairRowViewModel : PropertyRowViewModel { /// 왼쪽 칸 라벨(짧게 — X, 너비) public string LeftLabel { get; } /// 왼쪽 칸 행 public PropertyRowViewModel Left { get; } /// 오른쪽 칸 라벨 public string RightLabel { get; } /// 오른쪽 칸 행 public PropertyRowViewModel Right { get; } /// 한 줄이지만 속성은 둘 public override int LeafCount => 2; public PairRowViewModel(string leftLabel, PropertyRowViewModel left, string rightLabel, PropertyRowViewModel right) : base(leftLabel + "/" + rightLabel) { LeftLabel = leftLabel; Left = left; RightLabel = rightLabel; Right = right; } } /// 세그먼트 한 칸 — 아이콘 토글 하나 public sealed class SegmentItem { /// Lucide 아이콘명 public string Icon { get; } /// 툴팁(라벨 대용 — 아이콘만으로는 뜻이 안 서는 사용자를 위해) public string ToolTip { get; } /// 실제 값을 들고 있는 토글 행 public ToggleRowViewModel Toggle { get; } public SegmentItem(string icon, string toolTip, ToggleRowViewModel toggle) { Icon = icon; ToolTip = toolTip; Toggle = toggle; } } /// /// 토글 묶음을 한 줄 아이콘 세그먼트로 — 굵게·기울임·밑줄. /// 체크박스 하나에 한 줄씩 쓰면 세 줄(약 100px)을 먹는데, 서로 배타적이지 않은 같은 축의 /// 속성이라 한 줄에 모으는 편이 읽기도 쉽다. 과 같은 이유로 /// 값은 전부 자식 토글이 들고 있다. /// public sealed class SegmentRowViewModel : PropertyRowViewModel { /// 세그먼트 칸들 public List Items { get; } = new(); /// 한 줄이지만 속성은 칸 수만큼 public override int LeafCount => Items.Count; public SegmentRowViewModel(string label) : base(label) { } } /// 여러 줄 문자열 행(Text/수식/항목 목록) public sealed class MultilineTextRowViewModel : PropertyRowViewModel { public MultilineTextRowViewModel(string label) : base(label) { } } /// 숫자 행 — 문자열 바인딩, 커밋 시 숫자 검증은 소유자에서 public sealed class NumberRowViewModel : PropertyRowViewModel { public NumberRowViewModel(string label) : base(label) { } } /// 토글(참/거짓) 행 public sealed class ToggleRowViewModel : PropertyRowViewModel { /// /// 체크 상태 — "True"/"False" 문자열과 동기. 다중선택에서 값이 갈리면 null(불확정)이다. /// false 로 내리면 "전부 꺼짐"으로 보이고, 사용자가 한 번 눌러 켰다 끄면 전부 False 가 되어 /// 조용히 값이 뭉개진다. /// public bool? IsOn { get => IsMixed ? null : ValueText == "True"; set { if (value is bool on) { ValueText = on ? "True" : "False"; } } } public ToggleRowViewModel(string label) : base(label) { } protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn)); } /// 선택지 행 public sealed class ChoiceRowViewModel : PropertyRowViewModel { private readonly List choices; /// 선택지 목록 public IReadOnlyList Choices => choices; public ChoiceRowViewModel(string label, string[] choices) : base(label) { this.choices = choices.ToList(); } /// /// 현재 값이 선택지에 없으면 목록에 편입한다. /// ComboBox.SelectedItem 은 TwoWay 기본이라, 바인딩 값이 ItemsSource 에 없으면 null 로 코어스한 뒤 /// 그 null 을 소스에 되써서 속성이 삭제된다. 선택만 해도 데이터가 손상되므로 방어가 필요하다. /// public void EnsureChoice(string? value) { if (!string.IsNullOrEmpty(value) && !choices.Contains(value, StringComparer.Ordinal)) { choices.Add(value); } } } /// /// 태그 피커 행 — 값 표시 + 찾아보기 버튼(검색 대화상자). /// 목록에 없는 사이트 커스텀 태그는 텍스트 직접 입력도 허용. /// public sealed class TagPickerRowViewModel : PropertyRowViewModel { /// 피커 선택지(레거시 카탈로그) public IReadOnlyList Choices { get; } /// 피커 대화상자 제목 public string PickerTitle { get; } /// 찾아보기 — 검색 대화상자 열기 public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; } public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList choices) : base(label) { PickerTitle = pickerTitle; Choices = choices; BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse()); } private void OnBrowse() { var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText) { Owner = System.Windows.Application.Current.MainWindow, }; if (dialog.ShowDialog() == true) { ValueText = dialog.SelectedTag ?? string.Empty; } } } /// /// 마스크 행 — 값과 표시 미리보기를 함께 보여주고, 찾아보기로 프리셋 편집기를 연다. /// 레거시는 WinForms 기본 마스크 디자이너(프리셋 + 시험 입력)를 제공했다. /// public sealed class MaskRowViewModel : PropertyRowViewModel { /// 이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인 public string Preview => ValueText.Length == 0 ? "(마스크 없음)" : SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText); /// 마스크 편집기 열기 public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; } public MaskRowViewModel(string label) : base(label) { BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse()); } protected override void OnValueApplied() => OnPropertyChanged(nameof(Preview)); private void OnBrowse() { var dialog = new Views.MaskPickerDialogView(ValueText) { Owner = System.Windows.Application.Current.MainWindow, }; if (dialog.ShowDialog() == true) { ValueText = dialog.Mask; } } } /// SQL 쿼리 행 — 요약 표시 + 전용 편집기 창(치환 변수 삽입) public sealed class QueryRowViewModel : PropertyRowViewModel { /// 요약 텍스트(한 줄) public string Summary { get { var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim(); return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine; } } /// 전용 편집기 열기 public M.Framework.WPF.ICustomCommand? EditCommand { get; set; } public QueryRowViewModel(string label) : base(label) { EditCommand = new M.Framework.WPF.Command((sender, e) => OnEdit()); } protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary)); private void OnEdit() { var dialog = new Views.QueryEditorWindow(Label, ValueText) { Owner = System.Windows.Application.Current.MainWindow, }; if (dialog.ShowDialog() == true) { ValueText = dialog.QueryText; } } } /// 색 행 — 레거시 invariant 문자열("R, G, B"/명명색) + 미리보기 스와치 public sealed class ColorRowViewModel : PropertyRowViewModel { /// 미리보기 브러시 public Brush Preview { get { if (ValueText.Length == 0) { return Brushes.Transparent; } var (a, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(ValueText); var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b)); brush.Freeze(); return brush; } } /// 색상 피커 열기 — 확정 시 레거시 invariant 형식("R, G, B")으로 반영 public M.Framework.WPF.ICustomCommand? PickCommand { get; set; } public ColorRowViewModel(string label) : base(label) { PickCommand = new M.Framework.WPF.Command((sender, e) => OnPick()); } protected override void OnValueApplied() => OnPropertyChanged(nameof(Preview)); private void OnPick() { var initialHex = (string?)null; if (ValueText.Length > 0) { var (_, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(ValueText); initialHex = $"#{r:X2}{g:X2}{b:X2}"; } var picked = Views.ColorPickerWindow.Pick(System.Windows.Application.Current.MainWindow, initialHex); if (picked is null) { return; } var color = (Color)ColorConverter.ConvertFromString(picked); ValueText = Core.Serialization.LegacyFormat.FormatColor(255, color.R, color.G, color.B); } } /// 읽기 전용 행 — 중첩/바이너리/참조 등 raw 편집 불가 값 표시 public sealed class ReadOnlyRowViewModel : PropertyRowViewModel { public ReadOnlyRowViewModel(string label, string display) : base(label) { Initialize(display); } } /// 전체 속성(고급) 섹션 토글 행 — 표시/숨김 버튼 public sealed class ToggleAdvancedRowViewModel : PropertyRowViewModel { /// 버튼 표시 텍스트 public string ButtonText { get; } /// 토글 실행 public M.Framework.WPF.ICustomCommand? ToggleCommand { get; set; } public ToggleAdvancedRowViewModel(string buttonText, Action toggle) : base(string.Empty) { ButtonText = buttonText; ToggleCommand = new M.Framework.WPF.Command((sender, e) => toggle()); } } /// 속성 추가 행 — 키 입력 후 빈 속성 생성(고급) public sealed class AddPropertyRowViewModel : PropertyRowViewModel { private string keyText = string.Empty; /// 추가할 속성 키(레거시 Property 이름) public string KeyText { get => keyText; set => SetProperty(ref keyText, value); } /// 추가 실행 public M.Framework.WPF.ICustomCommand? AddCommand { get; set; } public AddPropertyRowViewModel(Action add) : base(string.Empty) { AddCommand = new M.Framework.WPF.Command((sender, e) => { var key = KeyText.Trim(); if (key.Length > 0) { add(key); } }); } } /// 인스펙터 행 컨텍스트 — 대상 컨트롤 집합과 접근자 public sealed class RowBinding { /// 값 읽기 public required Func Get { get; init; } /// 값 쓰기 public required Action Set { get; init; } /// 커밋 후 시각 재해석 필요 여부 public bool AffectsVisual { get; init; } = true; }