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