From 394807f8dfff9172215ade789ecb0f6583c0c6dc Mon Sep 17 00:00:00 2001 From: Msystech Date: Wed, 12 Aug 2026 14:12:29 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B8=EC=8A=A4=ED=8E=99=ED=84=B0=203?= =?UTF-8?q?=ED=83=AD=20=EB=B6=84=ED=95=A0=20=E2=80=94=20=EB=B9=88=20?= =?UTF-8?q?=ED=83=AD=EC=9D=80=20=EA=B7=B8=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 요청으로 [200] 식 「디자인 / 데이터 / 동작」 3탭을 넣었다. [분류] 속성 49종을 레거시 실사용 근거로 배정했다. 이름만 보고 찍으면 사용자가 속성 하나를 찾아 세 탭을 다 뒤지게 되므로, 각 속성이 레거시에서 실제로 어떻게 쓰이는지를 근거로 삼았다. · GetValue() 가 그 속성을 돌려주면 값이다 — CheckBox/RadioButton 의 Checked 는 IIf(Checked,'Y','') 이므로 초기값(데이터). · DateTimePicker.Format/CustomFormat 은 데이터처럼 보이지만 GetValue 가 Format 과 무관하게 항상 yyyyMMddHHmmss 를 돌려준다 — 그리기 문자열만 바꾸므로 디자인. 확정적. · Mask 는 WinForms 분류로는 Behavior 지만 실제로는 날짜·시간 입력 형식 제약이라 데이터. · Score 는 '점수'라는 이름과 달리 체크됐을 때만 값을 내는 데이터 항목이고 CalcBox.Formula 가 컨트롤명으로 합산한다. · 상용구 2종(RwdRsvWrdYon/RwdOrderAutYon)은 입력 보조처럼 보이지만 조작 훅이다 — 전자는 포커스 핸들러 부착 게이트, 후자는 상용구를 고른 결과로 OCS 처방을 전송하는 게이트. **동작**으로 정정했다 (초안에서는 데이터로 뒀다). 결과: 디자인 21 / 데이터 20 / 동작 8. [빈 탭은 그리지 않는다 — 이번 결정의 핵심] 분류를 마치고 세어 보니 **동작 속성을 가진 타입은 TextBox·Button 둘뿐이고 나머지 14타입(87.5%)에서 동작 탭이 통째로 빈다.** 데이터 탭도 6타입에서 빈다. 빈 탭을 남겨 두면 대부분의 컨트롤에서 '여긴 없습니다'만 보게 되고 사용자는 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다. 그래서 개수 0인 탭은 알약을 아예 그리지 않고, 남는 탭이 하나뿐이면 알약 줄 자체를 숨긴다. 결과적으로 라벨·선·패널·그룹상자·날짜선택은 탭 없이 평평하게 보이고, TextBox 만 3개가 뜬다. 현재 탭이 새 선택에서 비면 디자인으로 되돌린다 — 디자인은 공통·글꼴이 있어 절대 비지 않는 안전한 착지점이다. [유지] 선택한 탭은 컨트롤을 바꿔도 유지된다. 데이터 탭에서 여러 컨트롤의 태그를 잇달아 손보는 작업이 흔한데 매번 디자인으로 돌아가면 계속 다시 눌러야 한다([200] 은 매번 리셋된다 — 따르지 않았다). [탈출구는 쪼개지 않는다] '전체 속성(고급)'의 레거시 원문 키는 분류 근거가 없고 무엇보다 탈출구다. 탭에 가두면 '고급에도 없네' 상황이 생기므로 모든 탭 하단에 그대로 둔다. [분류표 위치] Core 가 아니라 Designer 계층에 뒀다(PaletteIconCatalog 선례). 탭 구성은 화면 사정이지 서식 데이터의 성질이 아니다. 모르는 키는 편집기 종류로 추론해 사이트 커스텀 속성에 대비한다. 검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 픽셀 차이 0. dialog-shots 진단에 데이터·동작 탭 스냅샷을 추가해 세 탭 전부를 양 테마로 확인. Co-Authored-By: Claude Opus 5 --- .../Diagnostics/DialogShots.cs | 7 +- .../Inspector/InspectorTabCatalog.cs | 90 ++++++++ .../Inspector/InspectorViewModel.cs | 194 +++++++++++++++--- src/SheetMe.Designer/Views/InspectorView.xaml | 99 +++++++++ 4 files changed, 359 insertions(+), 31 deletions(-) create mode 100644 src/SheetMe.Designer/ViewModels/Inspector/InspectorTabCatalog.cs diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs index 64abb4c..cae91a6 100644 --- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs +++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs @@ -6,6 +6,7 @@ using SheetMe.Core.Models; using SheetMe.Data.Stores; using SheetMe.Designer.Services; using SheetMe.Designer.ViewModels; +using SheetMe.Designer.ViewModels.Inspector; namespace SheetMe.Designer.Diagnostics; @@ -123,15 +124,19 @@ public static class DialogShots // 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다. ("09-inspector-label", () => HostInspector(designer, "Label1")), ("10-inspector-textbox", () => HostInspector(designer, "TextBox1")), + ("11-inspector-data", () => HostInspector(designer, "TextBox1", InspectorTab.Data)), + ("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)), }; } /// 인스펙터를 실제 패널 폭(300px)의 창에 담는다 — 컨트롤 하나를 선택한 상태로 - private static Window HostInspector(DesignerViewModel designer, string controlId) + private static Window HostInspector(DesignerViewModel designer, string controlId, + InspectorTab tab = InspectorTab.Design) { var page = designer.Pages[0]; var target = page.Controls.FirstOrDefault(c => c.Id == controlId) ?? page.Controls[0]; designer.Selection.SetSingle(target); + designer.Inspector.SelectedTab = tab; designer.Inspector.Rebuild(); return new Window diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorTabCatalog.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorTabCatalog.cs new file mode 100644 index 0000000..d8bb0af --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorTabCatalog.cs @@ -0,0 +1,90 @@ +using SheetMe.Core.Catalog; + +namespace SheetMe.Designer.ViewModels.Inspector; + +/// 인스펙터 상단 탭 +public enum InspectorTab +{ + /// 화면에 어떻게 보이는가 — 글자·색·정렬·테두리·크기·표시 형식 + Design, + + /// 무슨 값이 채워지는가 — 자동 채움 원천, 조회, 초기값, 입력 형식·검증, 저장 대상 + Data, + + /// 조작하면 무슨 일이 일어나는가 — 액션 태그, 대상 컨트롤, 단축키 + Behavior, +} + +/// +/// 속성 → 인스펙터 탭 분류. +/// +/// Core 에 두지 않는다. ControlRegistry 는 UI 무관하게 유지하고 표시 계층인 여기서만 +/// 매핑한다(PaletteIconCatalog 와 같은 선례). 탭 구성은 화면 사정이지 서식 데이터의 성질이 아니다. +/// +/// 분류가 틀리면 탭이 없느니만 못하다 — 사용자가 속성 하나를 찾아 세 탭을 다 뒤지게 된다. +/// 그래서 키를 하나씩 명시하고, 모르는 키는 이 아니라 +/// 편집기 종류로 추론한다(사이트가 추가한 커스텀 속성 대비). +/// +public static class InspectorTabCatalog +{ + #region Member Fields + private static readonly Dictionary Map = new(StringComparer.Ordinal) + { + // ── 디자인: 화면에 어떻게 보이는가 ── + ["Text"] = InspectorTab.Design, // 라벨·버튼·그룹상자 표시 문구 + ["TextAlign"] = InspectorTab.Design, + ["ForeColor"] = InspectorTab.Design, + ["BackColor"] = InspectorTab.Design, + ["LineColor"] = InspectorTab.Design, + ["BorderStyle"] = InspectorTab.Design, + ["SizeMode"] = InspectorTab.Design, // 이미지 맞춤 방식 + ["Format"] = InspectorTab.Design, // 날짜 표시 형식 + ["CustomFormat"] = InspectorTab.Design, + ["Multiline"] = InspectorTab.Design, // 여러 줄로 '보이는가' + + // ── 데이터: 무슨 값이 채워지는가 ── + ["DataInterfaceTag"] = InspectorTab.Data, // 자동 채움 원천 + ["DataTableField"] = InspectorTab.Data, // 저장 대상 필드 + ["Query"] = InspectorTab.Data, + ["ExcuteQuery"] = InspectorTab.Data, + ["InitialValue"] = InspectorTab.Data, + ["Mask"] = InspectorTab.Data, // 입력 형식 + ["IsRequiredValue"] = InspectorTab.Data, // 검증 + ["Items"] = InspectorTab.Data, // 고를 수 있는 값 목록 + ["Checked"] = InspectorTab.Data, // 기본 체크 = 초기값 + ["Score"] = InspectorTab.Data, // 설문 배점 + ["Formula"] = InspectorTab.Data, // 다른 컨트롤의 Score 를 합산해 이 칸의 값을 만든다 + + // ── 동작: 조작하면 무슨 일이 일어나는가 ── + ["DataActionTag"] = InspectorTab.Behavior, + ["DataActionTagControl"] = InspectorTab.Behavior, + ["GetDataActionTagControl"] = InspectorTab.Behavior, + ["KeyboardShortcut"] = InspectorTab.Behavior, + // 상용구 2종은 이름이 '입력 보조'처럼 보이지만 레거시에서는 조작 훅이다. + // RwdRsvWrdYon 은 포커스(Enter) 핸들러 부착 게이트라 상용구 창이 이 칸에 연동되고, + // RwdOrderAutYon 은 상용구를 고른 결과로 OCS 처방을 전송하는 게이트다. + ["RwdRsvWrdYon"] = InspectorTab.Behavior, + ["RwdOrderAutYon"] = InspectorTab.Behavior, + }; + #endregion + + #region Methods + /// 속성이 속한 탭 + public static InspectorTab TabOf(PropertyDef def) + { + if (Map.TryGetValue(def.Key, out var tab)) + { + return tab; + } + // 목록에 없는 키(사이트 커스텀 등)는 편집기 종류로 추론한다 — 무조건 디자인으로 보내면 + // 성격이 다른 속성이 디자인 탭에 쌓인다. + return def.Editor switch + { + PropEditorKind.DataActionTag => InspectorTab.Behavior, + PropEditorKind.DataInterfaceTag or PropEditorKind.SqlQuery or PropEditorKind.Mask + => InspectorTab.Data, + _ => InspectorTab.Design, + }; + } + #endregion +} diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs index a2208bb..217732d 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs @@ -21,6 +21,10 @@ public sealed class InspectorViewModel : ViewModelBase private readonly DesignerViewModel designer; private readonly List<(PropertyRowViewModel Row, RowBinding Binding)> boundsRows = new(); private SectionRowViewModel? currentSection; + private InspectorTab selectedTab = InspectorTab.Design; + private int designCount; + private int dataCount; + private int behaviorCount; private bool showAdvanced; /// 공통/글꼴 섹션이 이미 다루는 키 — 고급 목록에서 제외 @@ -37,6 +41,83 @@ public sealed class InspectorViewModel : ViewModelBase /// 선택된 컨트롤이 있는지 — 인스펙터 빈 상태 판정 public bool HasSelection => designer.Selection.Items.Count > 0; + /// + /// 선택된 탭. 컨트롤을 바꿔도 유지된다 — 데이터 탭에서 여러 컨트롤의 태그를 잇달아 손보는 + /// 작업이 흔한데, 선택할 때마다 디자인 탭으로 되돌아가면 매번 다시 눌러야 한다. + /// + public InspectorTab SelectedTab + { + get => selectedTab; + set + { + if (SetProperty(ref selectedTab, value)) + { + OnPropertyChanged(nameof(IsDesignTab)); + OnPropertyChanged(nameof(IsDataTab)); + OnPropertyChanged(nameof(IsBehaviorTab)); + Rebuild(); + } + } + } + + /// 탭 알약 바인딩 — ToggleButton 은 bool 만 받는다 + public bool IsDesignTab + { + get => selectedTab == InspectorTab.Design; + set { if (value) { SelectedTab = InspectorTab.Design; } else { OnPropertyChanged(nameof(IsDesignTab)); } } + } + + public bool IsDataTab + { + get => selectedTab == InspectorTab.Data; + set { if (value) { SelectedTab = InspectorTab.Data; } else { OnPropertyChanged(nameof(IsDataTab)); } } + } + + public bool IsBehaviorTab + { + get => selectedTab == InspectorTab.Behavior; + set { if (value) { SelectedTab = InspectorTab.Behavior; } else { OnPropertyChanged(nameof(IsBehaviorTab)); } } + } + + /// 탭 배지 — 눌러보지 않아도 볼 게 있는지 알 수 있게 + public int DesignCount + { + get => designCount; + private set => SetProperty(ref designCount, value); + } + + public int DataCount + { + get => dataCount; + private set => SetProperty(ref dataCount, value); + } + + public int BehaviorCount + { + get => behaviorCount; + private set => SetProperty(ref behaviorCount, value); + } + + /// 데이터 탭을 그릴지 — 이 컨트롤에 데이터 속성이 있을 때만 + public bool ShowDataTab => DataCount > 0; + + /// 동작 탭을 그릴지 — 이 컨트롤에 동작 속성이 있을 때만 + public bool ShowBehaviorTab => BehaviorCount > 0; + + /// 탭이 하나뿐이면 알약 줄 자체가 잡음이다 — 둘 이상일 때만 띄운다 + public bool ShowTabStrip => HasSelection && (ShowDataTab || ShowBehaviorTab); + + /// 선택은 있는데 이 탭에 보여줄 게 없는 상태 — 빈 화면 대신 안내를 띄운다 + public bool IsTabEmpty => HasSelection && Rows.Count == 0; + + /// 빈 탭 안내 문구 + public string EmptyTabText => selectedTab switch + { + InspectorTab.Data => "이 컨트롤에는 데이터 속성이 없습니다", + InspectorTab.Behavior => "이 컨트롤에는 동작 속성이 없습니다", + _ => "표시할 속성이 없습니다", + }; + /// 선택 요약 텍스트 public string Summary => designer.Selection.Items.Count switch @@ -84,51 +165,104 @@ public sealed class InspectorViewModel : ViewModelBase var items = designer.Selection.Items; if (items.Count == 0) { + RefreshTabCounts(null); return; } - // 공통: 이름/위치/크기 - AddSection("공통"); - if (items.Count == 1) - { - AddRow(new TextRowViewModel("이름"), new RowBinding - { - Get = vm => vm.Id, - Set = (vm, value) => RenameControl(vm, value), - AffectsVisual = false, - }); - } - // 위치·크기는 짝지어 한 줄씩 — 네 줄(약 136px)을 두 줄로 줄인다 - AddBoundsPair("X", vm => vm.X, (vm, v) => vm.X = v, - "Y", vm => vm.Y, (vm, v) => vm.Y = v); - AddBoundsPair("너비", vm => vm.Width, (vm, v) => vm.Width = Math.Max(1, v), - "높이", vm => vm.Height, (vm, v) => vm.Height = Math.Max(1, v)); - - // 폰트(공통) — Font Property(상속 시 빈 값) - AddSection("글꼴"); - AddFontRow("글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v); - AddFontRow("크기(pt)", f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture), - (f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0 ? size : f.SizePt); - AddFontStyleSegment(); - - // 타입 전용 — 전체 선택이 같은 타입일 때만 + // 같은 타입만 타입 전용 섹션을 낸다 — 탭 개수 계산도 이 조건을 따라야 어긋나지 않는다 var type = items[0].Type; - var curatedKeys = new HashSet(HandledKeys, StringComparer.Ordinal); - if (items.All(i => i.Type == type) && ControlRegistry.Find(type) is { } descriptor && descriptor.Properties.Count > 0) + var descriptor = items.All(i => i.Type == type) ? ControlRegistry.Find(type) : null; + RefreshTabCounts(descriptor); + + if (selectedTab == InspectorTab.Design) { - AddSection(descriptor.DisplayName); + // 공통: 이름/위치/크기 — 어느 컨트롤에나 있으므로 디자인 탭에 둔다 + AddSection("공통"); + if (items.Count == 1) + { + AddRow(new TextRowViewModel("이름"), new RowBinding + { + Get = vm => vm.Id, + Set = (vm, value) => RenameControl(vm, value), + AffectsVisual = false, + }); + } + // 위치·크기는 짝지어 한 줄씩 — 네 줄(약 136px)을 두 줄로 줄인다 + AddBoundsPair("X", vm => vm.X, (vm, v) => vm.X = v, + "Y", vm => vm.Y, (vm, v) => vm.Y = v); + AddBoundsPair("너비", vm => vm.Width, (vm, v) => vm.Width = Math.Max(1, v), + "높이", vm => vm.Height, (vm, v) => vm.Height = Math.Max(1, v)); + + // 폰트(공통) — Font Property(상속 시 빈 값) + AddSection("글꼴"); + AddFontRow("글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v); + AddFontRow("크기(pt)", f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture), + (f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0 ? size : f.SizePt); + AddFontStyleSegment(); + } + + // 타입 전용 — 선택 탭에 해당하는 속성만 + var curatedKeys = new HashSet(HandledKeys, StringComparer.Ordinal); + if (descriptor is not null) + { + // 큐레이션 키는 탭과 무관하게 전부 모아야 한다 — 고급 목록에서 중복 노출을 막는 용도라 + // 탭으로 걸러 버리면 다른 탭 속성이 고급에 다시 나온다. foreach (var def in descriptor.Properties) { - AddDefRow(def); curatedKeys.Add(def.Key); } + + var tabDefs = descriptor.Properties.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList(); + if (tabDefs.Count > 0) + { + AddSection(descriptor.DisplayName); + foreach (var def in tabDefs) + { + AddDefRow(def); + } + } } - // 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가) + // 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가). + // 레거시 원문 키는 분류 근거가 없어 어느 탭에도 속하지 않는다 — 탈출구이므로 모든 탭 아래에 둔다. if (items.Count == 1) { BuildAdvancedRows(items[0], curatedKeys); } + + OnPropertyChanged(nameof(IsTabEmpty)); + } + + /// + /// 탭 배지·표시 여부 갱신. + /// + /// 비어 있는 탭은 그리지 않는다. 레거시 속성을 실사용 근거로 분류해 보면 동작 속성을 + /// 가진 타입은 TextBox·Button 둘뿐이고 나머지 14타입에서는 동작 탭이 통째로 빈다 + /// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만 + /// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다. + /// + private void RefreshTabCounts(ControlDescriptor? descriptor) + { + var typed = descriptor?.Properties ?? (IReadOnlyList)Array.Empty(); + // 디자인 탭에는 공통 5 + 글꼴 6 이 항상 붙는다 → 선택이 있으면 절대 비지 않는다 + DesignCount = 11 + typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design); + DataCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Data); + BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior); + + // 현재 탭이 이 컨트롤에서 비면 내용 있는 탭으로 되돌린다 — 안 그러면 인스펙터가 통째로 빈다. + // 디자인은 공통·글꼴이 있어 절대 비지 않으므로 항상 안전한 착지점이다. + if ((selectedTab == InspectorTab.Data && DataCount == 0) || + (selectedTab == InspectorTab.Behavior && BehaviorCount == 0)) + { + selectedTab = InspectorTab.Design; + } + + OnPropertyChanged(nameof(IsDesignTab)); + OnPropertyChanged(nameof(IsDataTab)); + OnPropertyChanged(nameof(IsBehaviorTab)); + OnPropertyChanged(nameof(ShowDataTab)); + OnPropertyChanged(nameof(ShowBehaviorTab)); + OnPropertyChanged(nameof(ShowTabStrip)); } /// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가 diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml index 2e9328e..d9ac81d 100644 --- a/src/SheetMe.Designer/Views/InspectorView.xaml +++ b/src/SheetMe.Designer/Views/InspectorView.xaml @@ -10,6 +10,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -364,6 +447,22 @@ + + + + + + +