diff --git a/src/SheetMe.Core/Catalog/LegacyColorCatalog.cs b/src/SheetMe.Core/Catalog/LegacyColorCatalog.cs
new file mode 100644
index 0000000..6bd5e8d
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/LegacyColorCatalog.cs
@@ -0,0 +1,52 @@
+namespace SheetMe.Core.Catalog;
+
+///
+/// 색상 행 인라인 팔레트 — 운영 서식에서 실제로 쓰이는 값만 담는다.
+///
+/// 표준 색상표(빨/주/노/초…)를 늘어놓지 않는 이유가 있다. 실측(운영 디자인 1,271건의
+/// BackColor·ForeColor 60,319건)에서 상위 두 값이 전체의 84%를 차지한다 —
+/// White 29,010건(48%)과 "224, 224, 224" 21,497건(36%)이다. 뒤쪽은 꼬리가 길고 얇다.
+/// 자주 쓰는 값을 한 번에 고르게 하는 것이 목적이지 색을 발명하게 하는 것이 아니다.
+///
+/// 값은 레거시 원문 형식을 그대로 쓴다(명명색 또는 "R, G, B").
+/// 명명색을 RGB 로 풀면 원문과 달라져 왕복 diff 가 생기고, 시스템색의 의미도 사라진다.
+///
+public static class LegacyColorCatalog
+{
+ /// 팔레트 항목 — 저장값과 실사용 건수(툴팁 표시용)
+ public sealed record Entry(string Value, string Usage);
+
+ /// 배경색 계열 — 실측 상위순
+ public static IReadOnlyList Backgrounds { get; } = new[]
+ {
+ new Entry("White", "29,010건"),
+ new Entry("224, 224, 224", "21,497건"),
+ new Entry("Transparent", "792건"),
+ new Entry("Silver", "557건"),
+ new Entry("Window", "440건"),
+ new Entry("Gainsboro", "415건"),
+ new Entry("226, 226, 226", "308건"),
+ new Entry("LightGray", "245건"),
+ new Entry("WhiteSmoke", "239건"),
+ new Entry("DarkGray", "215건"),
+ new Entry("Control", "99건"),
+ new Entry("ControlLight", "64건"),
+ };
+
+ /// 글자색·강조 계열 — 실측 상위순
+ public static IReadOnlyList Foregrounds { get; } = new[]
+ {
+ new Entry("Black", "4,920건"),
+ new Entry("ControlText", "345건"),
+ new Entry("Blue", "69건"),
+ new Entry("Red", "64건"),
+ new Entry("Gray", "30건"),
+ new Entry("RoyalBlue", "14건"),
+ new Entry("LightYellow", "91건"),
+ new Entry("255, 255, 192", "86건"),
+ new Entry("Lavender", "78건"),
+ new Entry("PaleTurquoise", "109건"),
+ new Entry("Bisque", "51건"),
+ new Entry("Info", "55건"),
+ };
+}
diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs
index 62a18ba..50b34e9 100644
--- a/src/SheetMe.Designer/App.xaml.cs
+++ b/src/SheetMe.Designer/App.xaml.cs
@@ -157,6 +157,11 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunSpreadDump(args[1], args[2]);
}
+ if (args.Length >= 2 && args[0] == "--db-colors")
+ {
+ return Diagnostics.DbSmoke.RunColorReport(args[1]);
+ }
+
if (args.Length >= 2 && args[0] == "--db-pageprops")
{
return Diagnostics.DbSmoke.RunPagePropsReport(args[1]);
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index dac08e1..882e32b 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -227,6 +227,84 @@ public static class DbSmoke
return 0;
}
+ ///
+ /// 색 속성 값 전수 집계 — 색상 행의 인라인 팔레트를 실제 사용값으로 채우기 위한 근거.
+ /// 레거시는 명명색(White/Window/Control…)과 "R, G, B" 를 섞어 쓴다.
+ ///
+ public static int RunColorReport(string reportPath)
+ {
+ var lines = new List();
+ try
+ {
+ var config = ConfigService.Current;
+ var store = new OracleLegacyFormStore(config.ConnectionString);
+ var serializer = new LegacyXmlSerializer();
+ var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
+
+ var byKey = new Dictionary>(StringComparer.Ordinal);
+ var total = 0;
+
+ void Visit(Core.Models.ControlElement element)
+ {
+ foreach (var key in new[] { "BackColor", "ForeColor" })
+ {
+ if (element.Props.GetText(key) is not { } value || value.Length == 0)
+ {
+ continue;
+ }
+ if (!byKey.TryGetValue(key, out var bucket))
+ {
+ bucket = new Dictionary(StringComparer.Ordinal);
+ byKey[key] = bucket;
+ }
+ bucket[value] = bucket.GetValueOrDefault(value) + 1;
+ total++;
+ }
+ foreach (var child in element.Children)
+ {
+ Visit(child);
+ }
+ }
+
+ foreach (var sheet in sheets)
+ {
+ try
+ {
+ var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
+ if (raw is null)
+ {
+ continue;
+ }
+ foreach (var page in serializer.Read(raw.Value.Xml).Pages)
+ {
+ Visit(page.Root);
+ }
+ }
+ catch
+ {
+ // 색 집계는 통계용이라 개별 실패는 건너뛴다(왕복 검증은 --db-smoke 가 본다)
+ }
+ }
+
+ lines.Add($"색 속성 값 {total}건");
+ foreach (var (key, bucket) in byKey.OrderBy(p => p.Key, StringComparer.Ordinal))
+ {
+ lines.Add(string.Empty);
+ lines.Add($"[{key}] 서로 다른 값 {bucket.Count}종");
+ foreach (var (value, count) in bucket.OrderByDescending(p => p.Value).Take(25))
+ {
+ lines.Add($" {value,-22} {count,7}건");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ lines.Add($"예외: {ex}");
+ }
+ File.WriteAllLines(reportPath, lines);
+ return 0;
+ }
+
public static int RunGateReport(string reportPath)
{
try
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index 5b337fa..bd21f4a 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -545,6 +545,29 @@ public static class EditSmoke
}
Check("페이지 속성: 편집이 되돌리기 스텝", pg.Undo.CanUndo);
+ Check("페이지 속성: '선택 없음' 안내를 띄우지 않음", pg.Inspector.HasSelection);
+ Check("페이지 속성: 탭 스트립 숨김", !pg.Inspector.ShowTabStrip);
+
+ // 인라인 팔레트 — 견본은 레거시 원문 값을 그대로 넣는다.
+ // 명명색을 RGB 로 풀면 원문과 달라져 왕복 diff 가 생기고 시스템색의 의미도 잃는다.
+ if (colorRow is not null)
+ {
+ var swatch = colorRow.BackgroundSwatches.First(s => s.Value == "224, 224, 224");
+ colorRow.ApplySwatchCommand!.Execute(swatch);
+ Check("색 팔레트: 견본이 원문 값 그대로 적용",
+ pg.Pages[0].Model.Root.Props.GetText("BackColor") == "224, 224, 224");
+ Check("색 팔레트: 적용 후 닫힘", !colorRow.IsPaletteOpen);
+
+ var named = colorRow.BackgroundSwatches.First(s => s.Value == "Window");
+ colorRow.ApplySwatchCommand!.Execute(named);
+ Check("색 팔레트: 명명색은 이름 그대로 저장",
+ pg.Pages[0].Model.Root.Props.GetText("BackColor") == "Window");
+
+ // 비우기 = 키 제거(기본값 저장이 아니다) — 원래 없던 페이지를 원상태로 되돌릴 수 있어야 한다
+ colorRow.ClearCommand!.Execute(null);
+ Check("색 팔레트: 비우기는 키를 지운다",
+ pg.Pages[0].Model.Root.Props.GetText("BackColor") is null);
+ }
// 컨트롤을 고르면 페이지 모드에서 빠진다
pg.Selection.SetSingle(pg.Pages[0].Controls[0]);
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
index 580e554..cfa4b44 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
@@ -39,7 +39,11 @@ public sealed class InspectorViewModel : ViewModelBase
public ObservableCollection Rows { get; } = new();
/// 선택된 컨트롤이 있는지 — 인스펙터 빈 상태 판정
- public bool HasSelection => designer.Selection.Items.Count > 0;
+ ///
+ /// 보여 줄 대상이 있는가 — 컨트롤 선택 또는 페이지 속성 모드.
+ /// 페이지도 레거시에선 하나의 Object 라 '선택 없음' 안내를 띄우면 안 된다.
+ ///
+ public bool HasSelection => designer.Selection.Items.Count > 0 || designer.InspectedPage is not null;
///
/// 선택된 탭. 컨트롤을 바꿔도 유지된다 — 데이터 탭에서 여러 컨트롤의 태그를 잇달아 손보는
@@ -104,8 +108,9 @@ public sealed class InspectorViewModel : ViewModelBase
/// 동작 탭을 그릴지 — 이 컨트롤에 동작 속성이 있을 때만
public bool ShowBehaviorTab => BehaviorCount > 0;
- /// 탭이 하나뿐이면 알약 줄 자체가 잡음이다 — 둘 이상일 때만 띄운다
- public bool ShowTabStrip => HasSelection && (ShowDataTab || ShowBehaviorTab);
+ /// 탭이 하나뿐이면 알약 줄 자체가 잡음이다 — 둘 이상일 때만 띄운다.
+ /// 페이지 속성 모드에는 데이터·동작 탭이 없으므로 컨트롤 선택일 때만 본다.
+ public bool ShowTabStrip => designer.Selection.Items.Count > 0 && (ShowDataTab || ShowBehaviorTab);
/// 선택은 있는데 이 탭에 보여줄 게 없는 상태 — 빈 화면 대신 안내를 띄운다
public bool IsTabEmpty => HasSelection && Rows.Count == 0;
@@ -319,7 +324,17 @@ public sealed class InspectorViewModel : ViewModelBase
}
AddPageRow(page, backRow,
() => root.Props.GetText("BackColor") ?? DefaultPageBackColor,
- value => root.Props.SetText("BackColor", value.Length == 0 ? DefaultPageBackColor : value),
+ value =>
+ {
+ // 비우면 기본값을 '저장'하는 게 아니라 키를 지운다 — 레거시 PropertyGrid 의 재설정과 같고,
+ // 원래 이 키가 없던 페이지(12.7%)를 원상태로 되돌릴 수 있어야 한다
+ if (value.Length == 0)
+ {
+ root.Props.Remove("BackColor");
+ return;
+ }
+ root.Props.SetText("BackColor", value);
+ },
page.NotifyPaperChanged);
// 페이지 글꼴은 자식이 상속한다 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
index 21dd8f4..73488c1 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
@@ -512,6 +512,8 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
/// 색 행 — 레거시 invariant 문자열("R, G, B"/명명색) + 미리보기 스와치
public sealed class ColorRowViewModel : PropertyRowViewModel
{
+ private bool isPaletteOpen;
+
/// 미리보기 브러시
public Brush Preview
{
@@ -531,9 +533,47 @@ public sealed class ColorRowViewModel : PropertyRowViewModel
/// 색상 피커 열기 — 확정 시 레거시 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) => OnPick());
+ PickCommand = new M.Framework.WPF.Command((sender, e) =>
+ {
+ IsPaletteOpen = false;
+ OnPick();
+ });
+ 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));
@@ -556,6 +596,44 @@ public sealed class ColorRowViewModel : PropertyRowViewModel
}
}
+///
+/// 색 견본 하나 — 인라인 팔레트 항목.
+/// 는 화면 표시용 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
{
diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml
index b6a45bc..88e576e 100644
--- a/src/SheetMe.Designer/Views/InspectorView.xaml
+++ b/src/SheetMe.Designer/Views/InspectorView.xaml
@@ -424,26 +424,91 @@
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+