Files
SheetMe/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
T
MsystechandClaude Opus 5 9d20cc2971 [200] 디자인 동일화 2/2 — 좌측 패널·타이틀바·인스펙터 정보 밀도
1/2 가 껍데기(스크롤바·창 기본값)를 맞췄다면 이번은 그 안에 든 정보다. [200] 과의 실제 격차는
장식이 아니라 "한 화면에서 얼마나 읽히는가"였고, 그중 둘은 데이터 손상 경로이기도 했다.

[레이어] 행 라벨을 Model.Id → 신규 LayerName(Text 우선, 없으면 Id)으로. 운영 서식의 Id 는
대부분 Label12 꼴 자동 이름이라 291개짜리 목록이 아무것도 알려주지 않았다. Id 는 외부 참조
키이므로(DataTableField 대상명, EventHandlerMappingTag 의 ShtCod_컨트롤명 규약) 버리지 않고
툴팁 + 검색 대상으로 유지한다. 앞에 타입 아이콘(Accent), 뒤 숨김/잠금을 MiniToggle(eye/eye-off,
lock/unlock)로 — 사각 체크박스로는 상태가 안 읽혔다.
검색칩 신설(LayerFilter, 이름·Id 부분일치). 컨트롤 최대 1,067개 서식에서 스크롤만으로는 대상을
찾을 수 없다. 칩 안 TextBox 에 MinHeight=0 필수 — 전역 TextBox MinHeight 30 이 32px 칩에서
글자를 자른다.

[인스펙터·여러 값] IsMixed 가 설정만 되고 화면 어디에도 바인딩돼 있지 않았다. 다중선택에서 값이
갈리면 편집기가 빈 칸이 되는데 그 빈 칸은 "값 없음"과 구별되지 않는다. 무심코 입력하면 서로
달랐던 값이 전부 한 값으로 덮인다. 편집기 위에 비파괴 오버레이("여러 값")를 얹고, 포커스가
들어오면 감춘다 — 값은 LostFocus 에만 올라가므로 타이핑 중 ValueText 는 계속 "" 이고 안내가
방금 친 글자 위에 남는다.
토글 행은 IsOn 을 bool? 로 바꿔 3상태로. false 로 내리면 "전부 꺼짐"으로 보이고, 사용자가 한 번
켰다 끄면 전부 False 가 되어 조용히 뭉개진다.

[인스펙터·접이식] 섹션 헤더를 클릭 가능한 접기/펴기로(셰브런 + 소속 행 수). 고급 속성까지 켜면
수십 행이 된다. Rows 는 평면 컬렉션을 유지하고 섹션이 자기 행을 들고 있다가 컨테이너
Visibility 만 토글한다 — 행 VM 을 재생성하지 않아 편집 중 값·포커스가 보존되고, 다중선택
재구성이 잦은 이 화면에서 트리 재구축 비용도 피한다.

[타이틀바] 로고 메뉴 루트(셰브런) 아래로 5개 메뉴를 흡수하고, 편집 아이콘 스트립(실행취소·다시
실행 | 복사·삭제·그룹·해제)과 우측 액션(테마·저장·미리보기)을 배치. 중앙 제목은 뺐다 — 문서 탭이
같은 정보를 이미 들고 있다.

[빈 상태] 문서 0개 → 회색 허공 대신 다음 행동 3개(새 서식 / DB에서 열기 / 파일 열기).
선택 0개 → 인스펙터에 "선택 없음" 한 줄만 떠 패널이 고장 난 것처럼 보이던 것을 안내로 교체.

[그 외] 서식 목록에 하단 상태 한 줄(로딩·건수·DB 미접속) — 종전에는 목록 위에 "불러오는 중"
오버레이가 덮여 목록이 가려졌고 건수는 창 하단 전역 상태줄에만 있었다. 활성 문서 탭 아이콘을
Accent 로. 플로팅 바의 ±버튼 제거([200] 동일 — Ctrl+휠이 주 조작이고 배율 버튼이 플라이아웃을
겸한다), 대신 "화면 맞춤" 추가. ZoomFit 은 문서 전체가 아니라 활성 페이지 한 장 기준이다
(7페이지 서식을 통째로 맞추면 14% 가 되어 아무것도 안 보인다). 뷰포트 크기는 캔버스 뷰가
SizeChanged 에서 VM 에 통지 — VM 이 시각 트리를 몰라도 되게 한다.
열 기본 폭 232→248 / 280→300.

검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0.
**종이 렌더 P062 픽셀 대조 차이 0**(4,802바이트 전부 일치).
실 UI 확인: P062 레이어 행·인스펙터 접기, P093 291컨트롤에서 레이어 검색("알러지" → 10건),
116개 선택 시 X/Y/너비/높이 "여러 값" 표시.

남은 배치: E(선택 오버레이) · F(캔버스 크롬 — 종이에 닿는 유일한 배치) · G(토스트·ThemedDialog).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 10:33:48 +09:00

423 lines
14 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 bool IsMixed { get; private set; }
/// <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
{
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; }
#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 => Children.Count == 0 ? string.Empty : Children.Count.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>여러 줄 문자열 행(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 bool on)
{
ValueText = on ? "True" : "False";
}
}
}
public ToggleRowViewModel(string label) : base(label) { }
protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn));
}
/// <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>
/// 태그 피커 행 — 값 표시 + 찾아보기 버튼(검색 대화상자).
/// 목록에 없는 사이트 커스텀 태그는 텍스트 직접 입력도 허용.
/// </summary>
public sealed class TagPickerRowViewModel : PropertyRowViewModel
{
/// <summary>피커 선택지(레거시 카탈로그)</summary>
public IReadOnlyList<string> Choices { get; }
/// <summary>피커 대화상자 제목</summary>
public string PickerTitle { get; }
/// <summary>찾아보기 — 검색 대화상자 열기</summary>
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList<string> 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;
}
}
}
/// <summary>
/// 마스크 행 — 값과 표시 미리보기를 함께 보여주고, 찾아보기로 프리셋 편집기를 연다.
/// 레거시는 WinForms 기본 마스크 디자이너(프리셋 + 시험 입력)를 제공했다.
/// </summary>
public sealed class MaskRowViewModel : PropertyRowViewModel
{
/// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary>
public string Preview => 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
{
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
{
/// <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; }
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);
}
}
/// <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>속성 추가 행 — 키 입력 후 빈 속성 생성(고급)</summary>
public sealed class AddPropertyRowViewModel : PropertyRowViewModel
{
private string keyText = string.Empty;
/// <summary>추가할 속성 키(레거시 Property 이름)</summary>
public string KeyText
{
get => keyText;
set => SetProperty(ref keyText, value);
}
/// <summary>추가 실행</summary>
public M.Framework.WPF.ICustomCommand? AddCommand { get; set; }
public AddPropertyRowViewModel(Action<string> add) : base(string.Empty)
{
AddCommand = new M.Framework.WPF.Command((sender, e) =>
{
var key = KeyText.Trim();
if (key.Length > 0)
{
add(key);
}
});
}
}
/// <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;
}