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 string? Hint { get; set; } /// 라벨 툴팁 — 설명이 있으면 그것, 없으면 라벨 자신(잘림 대비) public string LabelToolTip => Hint ?? Label; /// 선택 대상들의 값이 서로 다른지 — "여러 값" 표시 public bool IsMixed { get; private set; } /// /// 값이 갈렸을 때 요약 칸에 쓰는 글귀 — 쿼리·마스크·배선처럼 값 대신 요약을 그리는 행용. /// /// 이 행들은 빈 칸이 아니라 "(쿼리 없음)"·"(마스크 없음)"·"(배선 없음)" 같은 단정을 그린다. /// 서로 다른 쿼리를 든 컨트롤 둘을 골랐을 때 "쿼리 없음"이 나오면 그건 빈 칸보다 나쁘다 — /// 없다고 믿고 새로 쓰면 양쪽의 원래 쿼리가 한꺼번에 사라진다. /// protected const string MixedSummary = "(여러 값)"; /// /// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용. /// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 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 { // null 을 문자열로 정규화한다. 뷰가 null 을 되밀 수 있는 경로가 있고 // (ComboBox.SelectedItem 은 TwoWay 기본이라 목록에 없는 값을 null 로 코어스한다) // 그 null 이 커밋까지 흘러가면 AddDefRow 의 value.Length 에서 NullReferenceException 이다. // 아래 SetProperty 도 "" 와 null 을 다른 값으로 보아 무의미한 커밋을 한 번 내보낸다. value ??= string.Empty; if (!SetProperty(ref valueText, value) || building) { return; } IsMixed = false; OnPropertyChanged(nameof(IsMixed)); Commit?.Invoke(value); OnValueApplied(); } } /// 커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용) public Action? Commit { get; set; } /// /// 연속 커밋을 Undo 한 스텝으로 묶을지 — ↑/↓ 연타용. /// 커밋 한 번이 문서 전체 딥클론 1회라, ↑ 를 20번 누르면 스냅샷 20개가 쌓여 /// 이력 용량(100)을 절반 가까이 갉아먹는다. 값이 아니라 커밋 방식만 바꾸므로 /// Initialize/Commit 규약에는 영향이 없다. /// public bool CoalesceUndo { 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 AlignCell : ViewModelBase { private readonly AlignRowViewModel owner; /// 저장되는 값(레거시 문자열 그대로 — TopLeft, Left …) public string Value { get; } /// 칸에 그릴 표시 — 9칸은 방향 화살표, 3칸은 정렬 아이콘명 public string Glyph { get; } /// Lucide 아이콘명(있으면 화살표 대신 아이콘) public string Icon { get; } /// 아이콘으로 그릴지 화살표로 그릴지 public bool HasIcon => Icon.Length > 0; public bool HasGlyph => Icon.Length == 0; /// 현재 선택된 칸인지 public bool IsCurrent => string.Equals(owner.ValueText, Value, StringComparison.Ordinal); /// 값 변경 통지 — 소유 행이 호출 public void NotifyCurrentChanged() => OnPropertyChanged(nameof(IsCurrent)); public AlignCell(AlignRowViewModel owner, string value, string glyph, string icon) { this.owner = owner; Value = value; Glyph = glyph; Icon = icon; } } /// /// 정렬 선택 — 콤보 대신 격자. /// /// 종전에는 "MiddleCenter" 같은 레거시 원문 열거값이 드롭다운에 그대로 떴다. 무슨 뜻인지 /// 알려면 아홉 개를 다 열어 봐야 하고, 고르고 나서도 맞게 골랐는지 글자로만 확인된다. /// 값 집합이 타입마다 다르므로(라벨 9값 ContentAlignment / 텍스트박스 3값) 칸 수에 따라 /// 3열 격자가 3×3 이나 1×3 으로 알아서 접힌다. /// public sealed class AlignRowViewModel : PropertyRowViewModel { /// 격자 칸들 — Choices 순서 그대로 public List Cells { get; } = new(); /// 칸 클릭 — 값 지정 public M.Framework.WPF.ICustomCommand? SelectCommand { get; } public AlignRowViewModel(string label, IEnumerable choices) : base(label) { foreach (var choice in choices) { Cells.Add(new AlignCell(this, choice, GlyphOf(choice), IconOf(choice))); } SelectCommand = new M.Framework.WPF.Command((object parameter) => { if (parameter is string value) { ValueText = value; } }); } /// 값이 바뀌면 어느 칸이 켜졌는지 다시 알린다 protected override void OnValueApplied() { foreach (var cell in Cells) { cell.NotifyCurrentChanged(); } } private static string IconOf(string choice) => choice switch { "Left" => "align-left", "Center" => "align-center", "Right" => "align-right", _ => string.Empty, }; /// ContentAlignment 9값은 방향 화살표가 가장 빨리 읽힌다 private static string GlyphOf(string choice) => choice switch { "TopLeft" => "↖", "TopCenter" => "↑", "TopRight" => "↗", "MiddleLeft" => "←", "MiddleCenter" => "•", "MiddleRight" => "→", "BottomLeft" => "↙", "BottomCenter" => "↓", "BottomRight" => "↘", _ => choice, }; } /// 세그먼트 한 칸 — 아이콘 토글 하나 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 not bool on) { return; } // 갈린 상태에서 누르면 켠다. WPF ToggleButton.OnToggle 은 IsChecked 가 null 이면 // IsThreeState 와 무관하게 false 로 간다(내부적으로 isChecked.HasValue — null 이면 false). // 그대로 두면 굵게를 켜려고 누른 한 번이 선택 전체를 '굵게 끔'으로 커밋한다. // 실측(edit-smoke): 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False. // 갈린 상태에서 setter 에 false 가 오는 경로는 이 클릭뿐이므로(사용자가 '꺼짐'을 본 적이 // 없다) 켜는 것으로 읽는다 — 디자이너 관례도 '혼합을 누르면 전부 켜기'다. if (IsMixed && !on) { on = true; } ValueText = on ? "True" : "False"; } } public ToggleRowViewModel(string label) : base(label) { } protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn)); } /// /// 글꼴 이름 행 — 설치된 글꼴 목록에서 고르거나 직접 입력한다. /// /// 왜 자유 입력만으로는 안 되나. 전에는 그냥 텍스트 칸이라 "돋움"을 매번 타이핑해야 했고, /// 한 글자만 틀려도 조용히 기본 글꼴로 떨어졌다(레거시는 못 찾은 이름에 대해 아무 말도 하지 않는다). /// /// 왜 목록만으로도 안 되나. 병원 PC 와 이 PC 의 설치 글꼴이 다르다. 목록에 가두면 /// 여기 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없게 되고, 손대는 순간 사라진다. /// 그래서 고르기도 되고 쓰기도 되는 칸이다. /// public sealed class FontFamilyRowViewModel : PropertyRowViewModel { private readonly Func isInstalled; /// 선택지 — 이 PC 에 설치된 글꼴 public IReadOnlyList Families { get; } /// /// 지금 값이 이 PC 에 없는 글꼴인가 — 그러면 이 화면이 병원과 다르게 보인다. /// 서식이 틀린 것이 아니므로 값은 건드리지 않고 알리기만 한다. /// public bool IsMissingFont => !IsMixed && ValueText.Length > 0 && !isInstalled(ValueText); /// 경고 문구 — 툴팁으로 보인다 public string MissingHint => $"'{ValueText}' 은(는) 이 PC 에 설치되어 있지 않습니다. " + "저장되는 값은 그대로지만, 화면에는 대체 글꼴로 그려집니다."; public FontFamilyRowViewModel(string label, IReadOnlyList families, Func isInstalled) : base(label) { Families = families; this.isInstalled = isInstalled; } protected override void OnValueApplied() { OnPropertyChanged(nameof(IsMissingFont)); OnPropertyChanged(nameof(MissingHint)); } } /// 선택지 행 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); } } /// /// 값이 갈린 다중 선택에서 '여러 값' 자리를 목록 맨 앞에 만든다(빈 문자열). /// /// 두 가지를 동시에 해결한다. /// ① 보이게 한다. 갈린 값은 ValueText 가 "" 라 콤보가 빈 칸으로 보이는데, /// 그건 '값 없음'과 구별되지 않는다. 목록에 자리가 있으면 "여러 값"으로 그릴 수 있다. /// ② 코어스를 막는다. 는 IsNullOrEmpty 에서 빠져나가므로 /// "" 를 편입하지 않는다. 목록에 없는 값을 SelectedItem 에 물리면 WPF Selector 가 /// null 로 코어스하고 TwoWay 로 되쓸 수 있다 — 그 경로는 실측(edit-smoke)에서 재현되지 /// 않았지만, 자리를 만들어 두면 코어스 자체가 성립하지 않으므로 따질 일이 없어진다. /// /// 사용자가 이 빈 항목을 다시 골라도 ""→"" 라 SetProperty 가 false 를 돌려주고 커밋이 없다. /// public void EnsureMixedPlaceholder() { if (!choices.Contains(string.Empty, StringComparer.Ordinal)) { choices.Insert(0, string.Empty); } } } /// /// 태그 피커 행 — 값 표시 + 찾아보기 버튼(검색 대화상자). /// 목록에 없는 사이트 커스텀 태그는 텍스트 직접 입력도 허용. /// public sealed class TagPickerRowViewModel : PropertyRowViewModel { /// 피커 선택지(레거시 카탈로그) public IReadOnlyList Choices { get; } /// 피커 대화상자 제목 public string PickerTitle { get; } /// /// 액션 태그 행인가 — 액션은 값이 아니라 동작이라 피커의 값·사유 칸 뜻이 다르다. /// 데이터 태그용 문구를 그대로 쓰면 149종 전부에서 그 칸이 거짓이 된다. /// public bool IsActionTag { get; init; } /// /// 카탈로그에 없는 이름인가 — 값은 건드리지 않고 표식만 낸다. /// /// 인라인 완성으로 직접 치는 것이 이 행의 주 경로인데 거기에는 검증이 하나도 없었다. /// 'PAT_이룸' 처럼 한 글자 틀리면 아무 표시 없이 커밋되고, 그 사실은 미리보기에서 /// 빈칸으로만 드러난다 — 빈칸의 원인이 오타인지 이 환자에게 자료가 없어서인지 가릴 수 없다. /// /// 막지는 않는다. 사이트마다 카탈로그에 없는 커스텀 태그를 쓰기 때문이다(글꼴 행과 같은 규칙). /// public bool IsUnknownTag => !IsMixed && ValueText.Length > 0 && !string.Equals(ValueText, "None", StringComparison.Ordinal) && !Choices.Contains(ValueText, StringComparer.Ordinal); /// 경고 문구 — 툴팁으로 보인다 public string UnknownTagHint => $"'{ValueText}' 은(는) 카탈로그에 없는 이름입니다. " + "사이트 전용 태그면 그대로 두고, 오타면 고치세요 — 값이 안 나오면 종이에서 빈칸이 됩니다."; /// /// 한 줄에 하나꼴로 접히는 긴 한글 태그를 몇 개까지 깔지. /// 인스펙터 폭이 좁아 칩이 세로로 쌓인다 — 8개를 넘기면 다른 속성이 화면 밖으로 밀린다. /// 나머지는 피커에 있고, 거기서도 자주 쓰는 것이 위로 온다. /// private const int MaxSuggestions = 8; /// 잘리지 않은 전체 순위 — 피커 정렬에 쓴다 private readonly IReadOnlyList preferredAll; /// /// 이 컨트롤 타입에서 자주 쓰이는 태그 — 빈도순. 실측 근거는 LegacyTagUsageCatalog 참조. /// 이미지의 데이터 태그처럼 운영 전체가 7종뿐인 경우가 있어, 500여 개 목록을 뒤질 이유가 없다. /// public IReadOnlyList Suggestions { get; } /// /// 제안을 보여 줄지. /// /// 전에는 ValueText.Length == 0 이었다 — 값이 정해지면 칩이 사라졌다. /// 그래서 잘못 고른 태그를 바꿀 때 도움이 끊겼다. 실사용에서 태그를 고치는 일은 /// 처음 고르는 일만큼 잦은데(운영 태그 사용 8,032건), 고치는 쪽만 맨손이었다. /// 이제 값이 있어도 남긴다. 대신 타이핑을 시작하면 접는다 — /// 그때부터는 아래 완성 목록이 같은 일을 더 정확하게 한다. /// 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; } /// 제안 선택 — 누르면 바로 값이 된다 public M.Framework.WPF.ICustomCommand? PickCommand { get; set; } public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList choices, IReadOnlyList? suggestions = null) : base(label) { PickerTitle = pickerTitle; Choices = choices; // 피커에는 전체 순위를 넘기고(preferredAll), 인스펙터 칩만 잘라 쓴다 preferredAll = suggestions ?? Array.Empty(); Suggestions = preferredAll.Take(MaxSuggestions).ToList(); BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse()); PickCommand = new M.Framework.WPF.Command((object parameter) => { if (parameter is string picked && picked.Length > 0) { ValueText = picked; } }); PropertyChanged += (_, e) => { if (e.PropertyName == nameof(ValueText)) { OnPropertyChanged(nameof(ShowSuggestions)); // 값이 바뀌면 '카탈로그에 없음' 표식도 다시 판정한다 OnPropertyChanged(nameof(IsUnknownTag)); OnPropertyChanged(nameof(UnknownTagHint)); } }; } private void OnBrowse() { var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText, preferredAll, IsActionTag) { Owner = System.Windows.Application.Current.MainWindow, }; if (dialog.ShowDialog() == true) { ValueText = dialog.SelectedTag ?? string.Empty; } } } /// /// 마스크 행 — 값과 표시 미리보기를 함께 보여주고, 찾아보기로 프리셋 편집기를 연다. /// 레거시는 WinForms 기본 마스크 디자이너(프리셋 + 시험 입력)를 제공했다. /// public sealed class MaskRowViewModel : PropertyRowViewModel { /// 이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인 public string Preview => IsMixed ? MixedSummary : 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 { if (IsMixed) { return MixedSummary; } 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 { private bool isPaletteOpen; /// 미리보기 브러시 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 bool IsPaletteOpen { get => isPaletteOpen; set => SetProperty(ref isPaletteOpen, value); } /// 자주 쓰는 배경색 견본 public IReadOnlyList BackgroundSwatches { get; } = Core.Catalog.LegacyColorCatalog.Backgrounds.Select(e => new ColorSwatchViewModel(e.Value, e.Usage)).ToList(); /// 자주 쓰는 글자색·강조 견본 public IReadOnlyList ForegroundSwatches { get; } = Core.Catalog.LegacyColorCatalog.Foregrounds.Select(e => new ColorSwatchViewModel(e.Value, e.Usage)).ToList(); /// 견본 선택 — 레거시 원문 값을 그대로 넣고 팔레트를 닫는다 public M.Framework.WPF.ICustomCommand? ApplySwatchCommand { get; set; } /// 비우기 — 속성값을 지운다(상속/기본값으로 되돌림) public M.Framework.WPF.ICustomCommand? ClearCommand { get; set; } public ColorRowViewModel(string label) : base(label) { PickCommand = new M.Framework.WPF.Command((sender, e) => { IsPaletteOpen = false; OnPick(); }); // Command 가 받는 대리자는 Action 다 — object? 로 선언하면 null 허용 여부가 어긋난다. // 파라미터는 항상 견본이 오지만, 바인딩이 어긋나면 다른 값이 올 수 있으므로 형 검사는 유지한다. ApplySwatchCommand = new M.Framework.WPF.Command((object parameter) => { if (parameter is ColorSwatchViewModel swatch) { ValueText = swatch.Value; } IsPaletteOpen = false; }); ClearCommand = new M.Framework.WPF.Command((sender, e) => { ValueText = string.Empty; IsPaletteOpen = false; }); } 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); } } /// /// 색 견본 하나 — 인라인 팔레트 항목. /// 는 화면 표시용 hex 가 아니라 레거시 원문 그대로(명명색 또는 "R, G, B")다. /// 명명색을 RGB 로 풀어 저장하면 원문과 달라져 왕복 diff 가 생기고, 시스템색이 갖는 의미(Window/Control)도 잃는다. /// public sealed class ColorSwatchViewModel { /// 저장될 레거시 값 public string Value { get; } /// 견본 채움 public Brush Preview { get; } /// 견본 윤곽 — 배경이 아니라 견본 자신의 명도로 정한다 public Brush Edge { get; } /// 툴팁 — 값 + 실사용 건수 public string ToolTip { get; } public ColorSwatchViewModel(string value, string usage) { Value = value; var (a, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(value); var fill = new SolidColorBrush(Color.FromArgb(a, r, g, b)); fill.Freeze(); Preview = fill; var luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0; var edge = new SolidColorBrush(luminance > 0.6 ? Color.FromRgb(0x8A, 0x8A, 0x8A) : Color.FromRgb(0xD0, 0xD0, 0xD0)); edge.Freeze(); Edge = edge; ToolTip = usage.Length > 0 ? $"{value} ({usage})" : value; } } /// 읽기 전용 행 — 중첩/바이너리/참조 등 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()); } } /// /// 데이터소스 배선 행 — 전용 대화상자로만 편집한다. /// /// 자유 텍스트로 두면 형식을 어긴 값이 저장되고, 런타임은 그 예외를 빈 catch 로 삼켜 /// 아무 표시 없이 값을 비워 둔다. 디자이너에서는 정상으로 보이므로 원인 추적이 어렵다. /// public sealed class DataTableFieldRowViewModel : PropertyRowViewModel { private readonly Func> tablesOf; /// 사람이 읽는 요약 — 배선이 없으면 안내 문구 public string Summary { get { if (IsMixed) { return MixedSummary; } if (ValueText.Length == 0) { return "(배선 없음)"; } var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText); return spec is null // 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다 ? $"⚠ 형식을 알 수 없음: {ValueText}" : $"{spec.TableName} · {spec.Field}" + (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex}행"); } } /// 편집기 열기 public M.Framework.WPF.ICustomCommand? EditCommand { get; set; } public DataTableFieldRowViewModel(string label, Func> tablesOf) : base(label) { this.tablesOf = tablesOf; EditCommand = new M.Framework.WPF.Command((sender, e) => OnEdit()); PropertyChanged += (_, e) => { if (e.PropertyName == nameof(ValueText)) { OnPropertyChanged(nameof(Summary)); } }; } private void OnEdit() { var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf()) { Owner = System.Windows.Application.Current.MainWindow, }; if (dialog.ShowDialog() == true && dialog.Result is not null) { ValueText = dialog.Result; } } } /// /// 속성 추가 행 — 키 입력 후 빈 속성 생성(고급). /// /// 제안 목록은 가 타입별로 준다. /// 자유 입력은 그대로 열어 둔다 — 목록은 제안이지 검증이 아니고, 사이트가 추가한 키도 있다. /// public sealed class AddPropertyRowViewModel : PropertyRowViewModel { private string keyText = string.Empty; /// 추가할 속성 키(레거시 Property 이름) public string KeyText { get => keyText; set { if (SetProperty(ref keyText, value)) { OnPropertyChanged(nameof(Matches)); OnPropertyChanged(nameof(HasMatches)); OnPropertyChanged(nameof(MoreCount)); OnPropertyChanged(nameof(MoreText)); OnPropertyChanged(nameof(HasMore)); } } } /// 이 타입에서 쓰이는 속성 키 전체(실DB 집계 기반) public IReadOnlyList Suggestions { get; } /// 한 번에 보여 주는 제안 수 — 인스펙터는 좁아서 더 깔면 목록이 화면을 먹는다 private const int MaxShown = 10; /// /// 입력 중인 글자로 좁힌 제안 — 빈 입력이면 전체에서 앞쪽 몇 개를 보여 준다. /// 부분 일치(Contains)까지 받는다: 사용자가 'Signature' 만 기억하고 'IsSignature' 는 모를 수 있다. /// public IReadOnlyList Matches => Filtered().Take(MaxShown).ToList(); /// 보여 주지 못하고 남은 제안 수(0 이면 표시 안 함) public int MoreCount => Math.Max(0, Filtered().Count() - MaxShown); /// 남은 제안 안내 문구 public string MoreText => MoreCount > 0 ? $"+{MoreCount}개 — 더 입력해 좁히세요" : string.Empty; /// 가려진 제안이 있는지 public bool HasMore => MoreCount > 0; /// 보여 줄 제안이 있는지 public bool HasMatches => Filtered().Any(); private IEnumerable Filtered() { var query = keyText.Trim(); if (query.Length == 0) { return Suggestions; } return Suggestions .Where(s => s.Contains(query, StringComparison.OrdinalIgnoreCase)) // 접두 일치를 앞에 — 'Print' 를 치면 PrintOutPut 이 PrintBackColor 보다 먼저 와야 자연스럽다 .OrderByDescending(s => s.StartsWith(query, StringComparison.OrdinalIgnoreCase)) .ThenBy(s => s, StringComparer.OrdinalIgnoreCase); } /// 추가 실행 public M.Framework.WPF.ICustomCommand? AddCommand { get; set; } /// 제안 선택 — 키를 채워 넣고 바로 추가한다 public M.Framework.WPF.ICustomCommand? PickCommand { get; set; } public AddPropertyRowViewModel(Action add, IReadOnlyList? suggestions = null) : base(string.Empty) { Suggestions = suggestions ?? Array.Empty(); AddCommand = new M.Framework.WPF.Command((sender, e) => { var key = KeyText.Trim(); if (key.Length > 0) { add(key); } }); PickCommand = new M.Framework.WPF.Command((object parameter) => { if (parameter is string picked && picked.Length > 0) { add(picked); } }); } } /// 인스펙터 행 컨텍스트 — 대상 컨트롤 집합과 접근자 public sealed class RowBinding { /// 값 읽기 public required Func Get { get; init; } /// 값 쓰기 public required Action Set { get; init; } /// 커밋 후 시각 재해석 필요 여부 public bool AffectsVisual { get; init; } = true; /// /// 커밋 전 값 검사 — false 면 아무것도 하지 않고 칸을 원래 표시로 되돌린다. /// /// 숫자 칸이 그 예다. 전에는 "100px" 같은 문자열도 스냅샷을 먼저 찍은 뒤 대상마다 /// TryParse 에 실패해 아무것도 안 바뀌었다 — 바뀐 것이 없는데 문서는 '수정됨'이 되고 /// 잘못된 문자열은 다음 재구성까지 칸에 남아 값인 척했다. 다중 선택은 좌표·크기가 /// 갈리는 것이 기본에 가까워 이 경로를 훨씬 자주 밟는다. /// public Func? Validate { get; init; } /// /// 대상이 둘 이상일 때 커밋 전에 받는 확인 — false 면 커밋하지 않는다(인자는 대상 수). /// /// 대상마다 서로 다른 고유값이 한 번에 사라지는 편집에만 붙인다(텍스트·항목 목록). /// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다 — 확인을 남발하면 아무도 안 읽는다. /// public Func? ConfirmBatch { get; init; } }