Files
SheetMe/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
T
MsystechandClaude Fable 5 e162d0fb6b 태그가 값과 사유를 사실대로 말하게 한다
세 가지가 <b>사실과 다른 말</b>을 하고 있었다.

## ① 로그인 사용자 태그 다섯 갈래가 피커에서만 비어 보였다

피커가 TagPreviewCatalog.FromUser 의 <b>5-인자 위임판</b>을 부르고 있었다.
그 판은 면허·전문의·직종·부서·부서전화를 빈 문자열로 채운다.

    FromUser(tag, id, name, mobile, office)
      → FromUser(tag, id, name, mobile, office, "", "", "", "")

그래서 의사면허번호·전문의번호·직종·부서·부서전화 태그를 피커에서 고르면
"이 계정에는 값이 비어 있습니다"가 뜨는데, 정작 종이에는 실제 값이 찍힌다.
사용자는 그 태그를 못 쓰는 것으로 판단하고 다른 것을 찾게 된다.

이 값들은 로그인 세션(HisUser 15컬럼)이 이미 들고 있다 — 조회를 더하지 않고 본판으로 넘긴다.

## ② 액션 태그 149종에 데이터 태그용 사유가 나갔다

액션 태그는 <b>값이 아니라 동작</b>이다(더블클릭·버튼이 실행한다).
그런데 피커가 데이터 태그와 같은 값 칸을 그려서
"환자 정보가 있어야 값이 나옵니다" 같은 문구가 149종 전부에 붙었다.
오른쪽 설명 칸이 통째로 거짓말을 하고 있었다.

행 종류를 받아 액션이면 "값이 아니라 동작입니다 — 더블클릭·버튼에서 실행됩니다" 한 줄만 둔다.

## ③ 오타 난 태그가 아무 표시 없이 커밋됐다

인라인으로 직접 치는 것이 이 행의 <b>주 경로</b>인데 거기에 검증이 하나도 없었다.
'PAT_이룸' 처럼 한 글자만 틀려도 조용히 저장되고, 그 사실은 미리보기에서 <b>빈칸</b>으로만 드러난다 —
빈칸의 원인이 오타인지 이 환자에게 자료가 없어서인지 가릴 방법이 없다.

글꼴 행과 <b>같은 규칙</b>으로 ⚠ 를 붙인다. 막지는 않는다 —
사이트마다 카탈로그에 없는 커스텀 태그를 쓰기 때문이다. 값은 한 글자도 건드리지 않는다.

## 곁가지 — 사유가 접속에 묶여 있었다

DB 가드가 값 계산보다 <b>위</b>에 있어서, 접속이 없으면 '값을 만들 수 없습니다' 사유까지
통째로 감춰졌다. 그 사유는 정적 판정이라 접속과 무관하다 —
정작 가장 필요한 상황(접속 안 된 단말에서 서식을 훑을 때) 안 보였다.
가드를 값 계산 직전으로 내렸다.

## 판정

- 단위 시험 388 · edit-smoke 390건 전건 통과 (태그 6건 추가)
- --dialog-shots 69장 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 08:42:03 +09:00

1132 lines
44 KiB
C#

using System.Windows.Media;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.ViewModels.Inspector;
/// <summary>
/// 인스펙터 행 ViewModel — 라벨 + 문자열 정규화 값. 커밋 시(값 변경 시에만) 소유자 콜백으로
/// Undo 스냅샷 → 전체 선택 대상 적용 → 시각 재해석이 수행된다.
/// </summary>
public abstract class PropertyRowViewModel : ViewModelBase
{
#region Member Fields
private string valueText = string.Empty;
private bool building;
#endregion
#region Properties
/// <summary>행 라벨(한글)</summary>
public string Label { get; }
/// <summary>
/// 라벨에 붙일 설명 — 값만으로는 오해할 수 있는 행에 쓴다.
/// (예: 저장된 값이 없어 기본값을 보여 주는 중이라는 사실)
/// </summary>
public string? Hint { get; set; }
/// <summary>라벨 툴팁 — 설명이 있으면 그것, 없으면 라벨 자신(잘림 대비)</summary>
public string LabelToolTip => Hint ?? Label;
/// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary>
public bool IsMixed { get; private set; }
/// <summary>
/// 값이 갈렸을 때 <b>요약 칸</b>에 쓰는 글귀 — 쿼리·마스크·배선처럼 값 대신 요약을 그리는 행용.
///
/// 이 행들은 빈 칸이 아니라 "(쿼리 없음)"·"(마스크 없음)"·"(배선 없음)" 같은 <b>단정</b>을 그린다.
/// 서로 다른 쿼리를 든 컨트롤 둘을 골랐을 때 "쿼리 없음"이 나오면 그건 빈 칸보다 나쁘다 —
/// 없다고 믿고 새로 쓰면 양쪽의 원래 쿼리가 한꺼번에 사라진다.
/// </summary>
protected const string MixedSummary = "(여러 값)";
/// <summary>
/// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용.
/// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼
/// 실제보다 적게 나온다. 사용자가 세는 것은 줄이 아니라 속성이다.
/// </summary>
public virtual int LeafCount => 1;
/// <summary>소속 섹션 — null 이면 섹션 밖(항상 표시)</summary>
public SectionRowViewModel? Section { get; set; }
/// <summary>접힌 섹션에 속하면 숨긴다 — ItemContainerStyle 이 바인딩</summary>
public bool IsRowVisible => Section is null || Section.IsExpanded;
/// <summary>표시 여부 통지(섹션 접기/펴기)</summary>
public void NotifyVisibilityChanged() => OnPropertyChanged(nameof(IsRowVisible));
/// <summary>정규화 문자열 값 — 파생 편집기가 형 변환</summary>
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();
}
}
/// <summary>커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용)</summary>
public Action<string>? Commit { get; set; }
/// <summary>
/// 연속 커밋을 Undo 한 스텝으로 묶을지 — ↑/↓ 연타용.
/// 커밋 한 번이 문서 전체 딥클론 1회라, ↑ 를 20번 누르면 스냅샷 20개가 쌓여
/// 이력 용량(100)을 절반 가까이 갉아먹는다. 값이 아니라 <b>커밋 방식만</b> 바꾸므로
/// Initialize/Commit 규약에는 영향이 없다.
/// </summary>
public bool CoalesceUndo { get; set; }
#endregion
#region Constructors
protected PropertyRowViewModel(string label)
{
Label = label;
}
#endregion
#region Methods
/// <summary>초기값 세팅(커밋 미발생)</summary>
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();
}
/// <summary>값 적용 후 파생 갱신 지점</summary>
protected virtual void OnValueApplied() { }
#endregion
}
/// <summary>
/// 구분 헤더 행 — 클릭으로 접기/펴기.
///
/// Rows 는 평면 컬렉션을 유지하고(다중선택 재구성이 잦아 트리 재구축 비용을 피한다) 섹션이 자기
/// 소속 행을 들고 있다가 IsRowVisible 만 갱신한다. 컨테이너 Visibility 만 바뀌므로 편집 중이던
/// 값·포커스 상태가 보존된다.
/// </summary>
public sealed class SectionRowViewModel : PropertyRowViewModel
{
private bool isExpanded = true;
/// <summary>이 섹션에 속한 행들</summary>
public List<PropertyRowViewModel> Children { get; } = new();
/// <summary>펼침 상태</summary>
public bool IsExpanded
{
get => isExpanded;
set
{
if (!SetProperty(ref isExpanded, value))
{
return;
}
OnPropertyChanged(nameof(ChevronIcon));
foreach (var child in Children)
{
child.NotifyVisibilityChanged();
}
}
}
/// <summary>펼침 표시 아이콘</summary>
public string ChevronIcon => isExpanded ? "chevron-down" : "chevron-right";
/// <summary>접힌 상태에서 몇 개가 숨어 있는지</summary>
public string CountText
{
get
{
var total = Children.Sum(c => c.LeafCount);
return total == 0 ? string.Empty : total.ToString();
}
}
/// <summary>헤더 클릭 — 접기/펴기</summary>
public M.Framework.WPF.ICustomCommand? ToggleCommand { get; }
public SectionRowViewModel(string label) : base(label)
{
ToggleCommand = new M.Framework.WPF.Command((sender, e) => IsExpanded = !IsExpanded);
}
/// <summary>행 편입 — InspectorViewModel 이 Rows.Add 와 함께 호출</summary>
public void Adopt(PropertyRowViewModel row)
{
row.Section = this;
Children.Add(row);
OnPropertyChanged(nameof(CountText));
}
}
/// <summary>한 줄 문자열 행</summary>
public sealed class TextRowViewModel : PropertyRowViewModel
{
public TextRowViewModel(string label) : base(label) { }
}
/// <summary>
/// 두 값을 한 줄에 나란히 두는 행 — X|Y, 너비|높이.
///
/// 자식 행은 <b>평범한 행 그대로</b>다. 값·커밋·"여러 값" 판정이 전부 자식 안에서 끝나므로
/// 커밋 규약(Initialize 는 Commit 을 내지 않고, 값이 바뀔 때만 Undo 스냅샷 1회)이 그대로 유지된다.
/// 한쪽만 값이 갈리는 다중선택도 자연히 옳게 나온다.
/// 이 행 자신은 컨테이너일 뿐이라 Commit 을 갖지 않는다.
/// </summary>
public sealed class PairRowViewModel : PropertyRowViewModel
{
/// <summary>왼쪽 칸 라벨(짧게 — X, 너비)</summary>
public string LeftLabel { get; }
/// <summary>왼쪽 칸 행</summary>
public PropertyRowViewModel Left { get; }
/// <summary>오른쪽 칸 라벨</summary>
public string RightLabel { get; }
/// <summary>오른쪽 칸 행</summary>
public PropertyRowViewModel Right { get; }
/// <summary>한 줄이지만 속성은 둘</summary>
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;
}
}
/// <summary>정렬 격자 한 칸</summary>
public sealed class AlignCell : ViewModelBase
{
private readonly AlignRowViewModel owner;
/// <summary>저장되는 값(레거시 문자열 그대로 — TopLeft, Left …)</summary>
public string Value { get; }
/// <summary>칸에 그릴 표시 — 9칸은 방향 화살표, 3칸은 정렬 아이콘명</summary>
public string Glyph { get; }
/// <summary>Lucide 아이콘명(있으면 화살표 대신 아이콘)</summary>
public string Icon { get; }
/// <summary>아이콘으로 그릴지 화살표로 그릴지</summary>
public bool HasIcon => Icon.Length > 0;
public bool HasGlyph => Icon.Length == 0;
/// <summary>현재 선택된 칸인지</summary>
public bool IsCurrent => string.Equals(owner.ValueText, Value, StringComparison.Ordinal);
/// <summary>값 변경 통지 — 소유 행이 호출</summary>
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;
}
}
/// <summary>
/// 정렬 선택 — 콤보 대신 격자.
///
/// 종전에는 "MiddleCenter" 같은 <b>레거시 원문 열거값</b>이 드롭다운에 그대로 떴다. 무슨 뜻인지
/// 알려면 아홉 개를 다 열어 봐야 하고, 고르고 나서도 맞게 골랐는지 글자로만 확인된다.
/// 값 집합이 타입마다 다르므로(라벨 9값 ContentAlignment / 텍스트박스 3값) 칸 수에 따라
/// 3열 격자가 3×3 이나 1×3 으로 알아서 접힌다.
/// </summary>
public sealed class AlignRowViewModel : PropertyRowViewModel
{
/// <summary>격자 칸들 — Choices 순서 그대로</summary>
public List<AlignCell> Cells { get; } = new();
/// <summary>칸 클릭 — 값 지정</summary>
public M.Framework.WPF.ICustomCommand? SelectCommand { get; }
public AlignRowViewModel(string label, IEnumerable<string> 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;
}
});
}
/// <summary>값이 바뀌면 어느 칸이 켜졌는지 다시 알린다</summary>
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,
};
/// <summary>ContentAlignment 9값은 방향 화살표가 가장 빨리 읽힌다</summary>
private static string GlyphOf(string choice) => choice switch
{
"TopLeft" => "↖",
"TopCenter" => "↑",
"TopRight" => "↗",
"MiddleLeft" => "←",
"MiddleCenter" => "•",
"MiddleRight" => "→",
"BottomLeft" => "↙",
"BottomCenter" => "↓",
"BottomRight" => "↘",
_ => choice,
};
}
/// <summary>세그먼트 한 칸 — 아이콘 토글 하나</summary>
public sealed class SegmentItem
{
/// <summary>Lucide 아이콘명</summary>
public string Icon { get; }
/// <summary>툴팁(라벨 대용 — 아이콘만으로는 뜻이 안 서는 사용자를 위해)</summary>
public string ToolTip { get; }
/// <summary>실제 값을 들고 있는 토글 행</summary>
public ToggleRowViewModel Toggle { get; }
public SegmentItem(string icon, string toolTip, ToggleRowViewModel toggle)
{
Icon = icon;
ToolTip = toolTip;
Toggle = toggle;
}
}
/// <summary>
/// 토글 묶음을 한 줄 아이콘 세그먼트로 — 굵게·기울임·밑줄.
/// 체크박스 하나에 한 줄씩 쓰면 세 줄(약 100px)을 먹는데, 서로 배타적이지 않은 같은 축의
/// 속성이라 한 줄에 모으는 편이 읽기도 쉽다. <see cref="PairRowViewModel"/> 과 같은 이유로
/// 값은 전부 자식 토글이 들고 있다.
/// </summary>
public sealed class SegmentRowViewModel : PropertyRowViewModel
{
/// <summary>세그먼트 칸들</summary>
public List<SegmentItem> Items { get; } = new();
/// <summary>한 줄이지만 속성은 칸 수만큼</summary>
public override int LeafCount => Items.Count;
public SegmentRowViewModel(string label) : base(label) { }
}
/// <summary>여러 줄 문자열 행(Text/수식/항목 목록)</summary>
public sealed class MultilineTextRowViewModel : PropertyRowViewModel
{
public MultilineTextRowViewModel(string label) : base(label) { }
}
/// <summary>숫자 행 — 문자열 바인딩, 커밋 시 숫자 검증은 소유자에서</summary>
public sealed class NumberRowViewModel : PropertyRowViewModel
{
public NumberRowViewModel(string label) : base(label) { }
}
/// <summary>토글(참/거짓) 행</summary>
public sealed class ToggleRowViewModel : PropertyRowViewModel
{
/// <summary>
/// 체크 상태 — "True"/"False" 문자열과 동기. 다중선택에서 값이 갈리면 null(불확정)이다.
/// false 로 내리면 "전부 꺼짐"으로 보이고, 사용자가 한 번 눌러 켰다 끄면 전부 False 가 되어
/// 조용히 값이 뭉개진다.
/// </summary>
public bool? IsOn
{
get => IsMixed ? null : ValueText == "True";
set
{
if (value is not bool on)
{
return;
}
// <b>갈린 상태에서 누르면 켠다.</b> WPF ToggleButton.OnToggle 은 IsChecked 가 null 이면
// IsThreeState 와 무관하게 false 로 간다(내부적으로 isChecked.HasValue — null 이면 false).
// 그대로 두면 굵게를 <b>켜려고</b> 누른 한 번이 선택 전체를 '굵게 끔'으로 커밋한다.
// 실측(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));
}
/// <summary>
/// 글꼴 이름 행 — 설치된 글꼴 목록에서 고르거나 직접 입력한다.
///
/// <b>왜 자유 입력만으로는 안 되나.</b> 전에는 그냥 텍스트 칸이라 "돋움"을 매번 타이핑해야 했고,
/// 한 글자만 틀려도 조용히 기본 글꼴로 떨어졌다(레거시는 못 찾은 이름에 대해 아무 말도 하지 않는다).
///
/// <b>왜 목록만으로도 안 되나.</b> 병원 PC 와 이 PC 의 설치 글꼴이 다르다. 목록에 가두면
/// 여기 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없게 되고, 손대는 순간 사라진다.
/// 그래서 <b>고르기도 되고 쓰기도 되는</b> 칸이다.
/// </summary>
public sealed class FontFamilyRowViewModel : PropertyRowViewModel
{
private readonly Func<string, bool> isInstalled;
/// <summary>선택지 — 이 PC 에 설치된 글꼴</summary>
public IReadOnlyList<string> Families { get; }
/// <summary>
/// 지금 값이 이 PC 에 없는 글꼴인가 — 그러면 <b>이 화면이 병원과 다르게 보인다</b>.
/// 서식이 틀린 것이 아니므로 값은 건드리지 않고 알리기만 한다.
/// </summary>
public bool IsMissingFont => !IsMixed && ValueText.Length > 0 && !isInstalled(ValueText);
/// <summary>경고 문구 — 툴팁으로 보인다</summary>
public string MissingHint => $"'{ValueText}' 은(는) 이 PC 에 설치되어 있지 않습니다. "
+ "저장되는 값은 그대로지만, 화면에는 대체 글꼴로 그려집니다.";
public FontFamilyRowViewModel(string label, IReadOnlyList<string> families, Func<string, bool> isInstalled)
: base(label)
{
Families = families;
this.isInstalled = isInstalled;
}
protected override void OnValueApplied()
{
OnPropertyChanged(nameof(IsMissingFont));
OnPropertyChanged(nameof(MissingHint));
}
}
/// <summary>선택지 행</summary>
public sealed class ChoiceRowViewModel : PropertyRowViewModel
{
private readonly List<string> choices;
/// <summary>선택지 목록</summary>
public IReadOnlyList<string> Choices => choices;
public ChoiceRowViewModel(string label, string[] choices) : base(label)
{
this.choices = choices.ToList();
}
/// <summary>
/// 현재 값이 선택지에 없으면 목록에 편입한다.
/// ComboBox.SelectedItem 은 TwoWay 기본이라, 바인딩 값이 ItemsSource 에 없으면 null 로 코어스한 뒤
/// 그 null 을 소스에 되써서 <b>속성이 삭제된다</b>. 선택만 해도 데이터가 손상되므로 방어가 필요하다.
/// </summary>
public void EnsureChoice(string? value)
{
if (!string.IsNullOrEmpty(value) && !choices.Contains(value, StringComparer.Ordinal))
{
choices.Add(value);
}
}
/// <summary>
/// 값이 갈린 다중 선택에서 <b>'여러 값' 자리를 목록 맨 앞에 만든다</b>(빈 문자열).
///
/// 두 가지를 동시에 해결한다.
/// ① <b>보이게 한다.</b> 갈린 값은 ValueText 가 "" 라 콤보가 빈 칸으로 보이는데,
/// 그건 '값 없음'과 구별되지 않는다. 목록에 자리가 있으면 "여러 값"으로 그릴 수 있다.
/// ② <b>코어스를 막는다.</b> <see cref="EnsureChoice"/> 는 IsNullOrEmpty 에서 빠져나가므로
/// "" 를 편입하지 않는다. 목록에 없는 값을 SelectedItem 에 물리면 WPF Selector 가
/// null 로 코어스하고 TwoWay 로 되쓸 수 있다 — 그 경로는 실측(edit-smoke)에서 재현되지
/// 않았지만, 자리를 만들어 두면 코어스 자체가 성립하지 않으므로 따질 일이 없어진다.
///
/// 사용자가 이 빈 항목을 다시 골라도 ""→"" 라 SetProperty 가 false 를 돌려주고 커밋이 없다.
/// </summary>
public void EnsureMixedPlaceholder()
{
if (!choices.Contains(string.Empty, StringComparer.Ordinal))
{
choices.Insert(0, string.Empty);
}
}
}
/// <summary>
/// 태그 피커 행 — 값 표시 + 찾아보기 버튼(검색 대화상자).
/// 목록에 없는 사이트 커스텀 태그는 텍스트 직접 입력도 허용.
/// </summary>
public sealed class TagPickerRowViewModel : PropertyRowViewModel
{
/// <summary>피커 선택지(레거시 카탈로그)</summary>
public IReadOnlyList<string> Choices { get; }
/// <summary>피커 대화상자 제목</summary>
public string PickerTitle { get; }
/// <summary>
/// 액션 태그 행인가 — 액션은 <b>값이 아니라 동작</b>이라 피커의 값·사유 칸 뜻이 다르다.
/// 데이터 태그용 문구를 그대로 쓰면 149종 전부에서 그 칸이 거짓이 된다.
/// </summary>
public bool IsActionTag { get; init; }
/// <summary>
/// 카탈로그에 없는 이름인가 — <b>값은 건드리지 않고</b> 표식만 낸다.
///
/// 인라인 완성으로 직접 치는 것이 이 행의 주 경로인데 거기에는 검증이 하나도 없었다.
/// 'PAT_이룸' 처럼 한 글자 틀리면 아무 표시 없이 커밋되고, 그 사실은 미리보기에서
/// <b>빈칸</b>으로만 드러난다 — 빈칸의 원인이 오타인지 이 환자에게 자료가 없어서인지 가릴 수 없다.
///
/// 막지는 않는다. 사이트마다 카탈로그에 없는 커스텀 태그를 쓰기 때문이다(글꼴 행과 같은 규칙).
/// </summary>
public bool IsUnknownTag => !IsMixed && ValueText.Length > 0
&& !string.Equals(ValueText, "None", StringComparison.Ordinal)
&& !Choices.Contains(ValueText, StringComparer.Ordinal);
/// <summary>경고 문구 — 툴팁으로 보인다</summary>
public string UnknownTagHint => $"'{ValueText}' 은(는) 카탈로그에 없는 이름입니다. "
+ "사이트 전용 태그면 그대로 두고, 오타면 고치세요 — 값이 안 나오면 종이에서 빈칸이 됩니다.";
/// <summary>
/// 한 줄에 하나꼴로 접히는 긴 한글 태그를 몇 개까지 깔지.
/// 인스펙터 폭이 좁아 칩이 세로로 쌓인다 — 8개를 넘기면 다른 속성이 화면 밖으로 밀린다.
/// 나머지는 피커에 있고, 거기서도 자주 쓰는 것이 위로 온다.
/// </summary>
private const int MaxSuggestions = 8;
/// <summary>잘리지 않은 전체 순위 — 피커 정렬에 쓴다</summary>
private readonly IReadOnlyList<string> preferredAll;
/// <summary>
/// 이 컨트롤 타입에서 자주 쓰이는 태그 — 빈도순. 실측 근거는 LegacyTagUsageCatalog 참조.
/// 이미지의 데이터 태그처럼 운영 전체가 7종뿐인 경우가 있어, 500여 개 목록을 뒤질 이유가 없다.
/// </summary>
public IReadOnlyList<string> Suggestions { get; }
/// <summary>
/// 제안을 보여 줄지.
///
/// 전에는 <c>ValueText.Length == 0</c> 이었다 — 값이 정해지면 칩이 사라졌다.
/// 그래서 <b>잘못 고른 태그를 바꿀 때 도움이 끊겼다</b>. 실사용에서 태그를 고치는 일은
/// 처음 고르는 일만큼 잦은데(운영 태그 사용 8,032건), 고치는 쪽만 맨손이었다.
/// 이제 값이 있어도 남긴다. 대신 <b>타이핑을 시작하면 접는다</b> —
/// 그때부터는 아래 완성 목록이 같은 일을 더 정확하게 한다.
/// </summary>
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>
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
/// <summary>제안 선택 — 누르면 바로 값이 된다</summary>
public M.Framework.WPF.ICustomCommand? PickCommand { get; set; }
public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList<string> choices,
IReadOnlyList<string>? suggestions = null) : base(label)
{
PickerTitle = pickerTitle;
Choices = choices;
// 피커에는 전체 순위를 넘기고(preferredAll), 인스펙터 칩만 잘라 쓴다
preferredAll = suggestions ?? Array.Empty<string>();
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;
}
}
}
/// <summary>
/// 마스크 행 — 값과 표시 미리보기를 함께 보여주고, 찾아보기로 프리셋 편집기를 연다.
/// 레거시는 WinForms 기본 마스크 디자이너(프리셋 + 시험 입력)를 제공했다.
/// </summary>
public sealed class MaskRowViewModel : PropertyRowViewModel
{
/// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary>
public string Preview => IsMixed
? MixedSummary
: ValueText.Length == 0
? "(마스크 없음)"
: SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText);
/// <summary>마스크 편집기 열기</summary>
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;
}
}
}
/// <summary>SQL 쿼리 행 — 요약 표시 + 전용 편집기 창(치환 변수 삽입)</summary>
public sealed class QueryRowViewModel : PropertyRowViewModel
{
/// <summary>요약 텍스트(한 줄)</summary>
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;
}
}
/// <summary>전용 편집기 열기</summary>
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;
}
}
}
/// <summary>색 행 — 레거시 invariant 문자열("R, G, B"/명명색) + 미리보기 스와치</summary>
public sealed class ColorRowViewModel : PropertyRowViewModel
{
private bool isPaletteOpen;
/// <summary>미리보기 브러시</summary>
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;
}
}
/// <summary>색상 피커 열기 — 확정 시 레거시 invariant 형식("R, G, B")으로 반영</summary>
public M.Framework.WPF.ICustomCommand? PickCommand { get; set; }
/// <summary>인라인 팔레트 열림 — 스와치를 누르면 토글된다</summary>
public bool IsPaletteOpen
{
get => isPaletteOpen;
set => SetProperty(ref isPaletteOpen, value);
}
/// <summary>자주 쓰는 배경색 견본</summary>
public IReadOnlyList<ColorSwatchViewModel> BackgroundSwatches { get; } =
Core.Catalog.LegacyColorCatalog.Backgrounds.Select(e => new ColorSwatchViewModel(e.Value, e.Usage)).ToList();
/// <summary>자주 쓰는 글자색·강조 견본</summary>
public IReadOnlyList<ColorSwatchViewModel> ForegroundSwatches { get; } =
Core.Catalog.LegacyColorCatalog.Foregrounds.Select(e => new ColorSwatchViewModel(e.Value, e.Usage)).ToList();
/// <summary>견본 선택 — 레거시 원문 값을 그대로 넣고 팔레트를 닫는다</summary>
public M.Framework.WPF.ICustomCommand? ApplySwatchCommand { get; set; }
/// <summary>비우기 — 속성값을 지운다(상속/기본값으로 되돌림)</summary>
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> 다 — 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);
}
}
/// <summary>
/// 색 견본 하나 — 인라인 팔레트 항목.
/// <see cref="Value"/> 는 화면 표시용 hex 가 아니라 <b>레거시 원문 그대로</b>(명명색 또는 "R, G, B")다.
/// 명명색을 RGB 로 풀어 저장하면 원문과 달라져 왕복 diff 가 생기고, 시스템색이 갖는 의미(Window/Control)도 잃는다.
/// </summary>
public sealed class ColorSwatchViewModel
{
/// <summary>저장될 레거시 값</summary>
public string Value { get; }
/// <summary>견본 채움</summary>
public Brush Preview { get; }
/// <summary>견본 윤곽 — 배경이 아니라 견본 자신의 명도로 정한다</summary>
public Brush Edge { get; }
/// <summary>툴팁 — 값 + 실사용 건수</summary>
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;
}
}
/// <summary>읽기 전용 행 — 중첩/바이너리/참조 등 raw 편집 불가 값 표시</summary>
public sealed class ReadOnlyRowViewModel : PropertyRowViewModel
{
public ReadOnlyRowViewModel(string label, string display) : base(label)
{
Initialize(display);
}
}
/// <summary>전체 속성(고급) 섹션 토글 행 — 표시/숨김 버튼</summary>
public sealed class ToggleAdvancedRowViewModel : PropertyRowViewModel
{
/// <summary>버튼 표시 텍스트</summary>
public string ButtonText { get; }
/// <summary>토글 실행</summary>
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());
}
}
/// <summary>
/// 데이터소스 배선 행 — 전용 대화상자로만 편집한다.
///
/// 자유 텍스트로 두면 형식을 어긴 값이 저장되고, 런타임은 그 예외를 빈 catch 로 삼켜
/// <b>아무 표시 없이</b> 값을 비워 둔다. 디자이너에서는 정상으로 보이므로 원인 추적이 어렵다.
/// </summary>
public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
{
private readonly Func<IReadOnlyList<string>> tablesOf;
/// <summary>사람이 읽는 요약 — 배선이 없으면 안내 문구</summary>
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}행");
}
}
/// <summary>편집기 열기</summary>
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
public DataTableFieldRowViewModel(string label, Func<IReadOnlyList<string>> 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;
}
}
}
/// <summary>
/// 속성 추가 행 — 키 입력 후 빈 속성 생성(고급).
///
/// 제안 목록은 <see cref="SheetMe.Core.Catalog.LegacyPropertyCatalog"/> 가 타입별로 준다.
/// 자유 입력은 그대로 열어 둔다 — 목록은 제안이지 검증이 아니고, 사이트가 추가한 키도 있다.
/// </summary>
public sealed class AddPropertyRowViewModel : PropertyRowViewModel
{
private string keyText = string.Empty;
/// <summary>추가할 속성 키(레거시 Property 이름)</summary>
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));
}
}
}
/// <summary>이 타입에서 쓰이는 속성 키 전체(실DB 집계 기반)</summary>
public IReadOnlyList<string> Suggestions { get; }
/// <summary>한 번에 보여 주는 제안 수 — 인스펙터는 좁아서 더 깔면 목록이 화면을 먹는다</summary>
private const int MaxShown = 10;
/// <summary>
/// 입력 중인 글자로 좁힌 제안 — 빈 입력이면 전체에서 앞쪽 몇 개를 보여 준다.
/// 부분 일치(Contains)까지 받는다: 사용자가 'Signature' 만 기억하고 'IsSignature' 는 모를 수 있다.
/// </summary>
public IReadOnlyList<string> Matches => Filtered().Take(MaxShown).ToList();
/// <summary>보여 주지 못하고 남은 제안 수(0 이면 표시 안 함)</summary>
public int MoreCount => Math.Max(0, Filtered().Count() - MaxShown);
/// <summary>남은 제안 안내 문구</summary>
public string MoreText => MoreCount > 0 ? $"+{MoreCount}개 — 더 입력해 좁히세요" : string.Empty;
/// <summary>가려진 제안이 있는지</summary>
public bool HasMore => MoreCount > 0;
/// <summary>보여 줄 제안이 있는지</summary>
public bool HasMatches => Filtered().Any();
private IEnumerable<string> 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);
}
/// <summary>추가 실행</summary>
public M.Framework.WPF.ICustomCommand? AddCommand { get; set; }
/// <summary>제안 선택 — 키를 채워 넣고 바로 추가한다</summary>
public M.Framework.WPF.ICustomCommand? PickCommand { get; set; }
public AddPropertyRowViewModel(Action<string> add, IReadOnlyList<string>? suggestions = null) : base(string.Empty)
{
Suggestions = suggestions ?? Array.Empty<string>();
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);
}
});
}
}
/// <summary>인스펙터 행 컨텍스트 — 대상 컨트롤 집합과 접근자</summary>
public sealed class RowBinding
{
/// <summary>값 읽기</summary>
public required Func<ControlViewModel, string?> Get { get; init; }
/// <summary>값 쓰기</summary>
public required Action<ControlViewModel, string> Set { get; init; }
/// <summary>커밋 후 시각 재해석 필요 여부</summary>
public bool AffectsVisual { get; init; } = true;
/// <summary>
/// 커밋 전 값 검사 — false 면 <b>아무것도 하지 않고</b> 칸을 원래 표시로 되돌린다.
///
/// 숫자 칸이 그 예다. 전에는 "100px" 같은 문자열도 스냅샷을 먼저 찍은 뒤 대상마다
/// TryParse 에 실패해 아무것도 안 바뀌었다 — <b>바뀐 것이 없는데 문서는 '수정됨'</b>이 되고
/// 잘못된 문자열은 다음 재구성까지 칸에 남아 값인 척했다. 다중 선택은 좌표·크기가
/// 갈리는 것이 기본에 가까워 이 경로를 훨씬 자주 밟는다.
/// </summary>
public Func<string, bool>? Validate { get; init; }
/// <summary>
/// 대상이 둘 이상일 때 커밋 전에 받는 확인 — false 면 커밋하지 않는다(인자는 대상 수).
///
/// 대상마다 <b>서로 다른 고유값</b>이 한 번에 사라지는 편집에만 붙인다(텍스트·항목 목록).
/// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다 — 확인을 남발하면 아무도 안 읽는다.
/// </summary>
public Func<int, bool>? ConfirmBatch { get; init; }
}