색상 행에 인라인 팔레트 — 스와치를 누르면 그 자리에서 고르고, '자세히…' 로 피커

색을 바꾸려면 매번 대화상자를 열어야 했다. 실측해 보니 그럴 이유가 없다 —
--db-colors 진단을 신설해 운영 디자인 1,271건의 BackColor·ForeColor 60,319건을 집계한 결과
상위 두 값이 전체의 84%다: White 29,010건(48%), "224, 224, 224" 21,497건(36%).
뒤는 꼬리가 길고 얇다(Transparent 792 · Silver 557 · Window 440 · Gainsboro 415 …).
ForeColor 는 Black 4,920건(85%) · ControlText 345건이 사실상 전부.

스와치를 토글로 바꾸고 팝업에 실사용 상위 값을 배경 12종 / 글자·강조 12종으로 깔았다.
표준 색상표를 늘어놓지 않은 것은 의도다 — 목적은 자주 쓰는 값을 한 번에 고르게 하는 것이지
색을 발명하게 하는 것이 아니다. 임의 색은 팝업 아래 '자세히…' 로 기존 피커
(채도·명도 사각형 + 색상 슬라이더 + HEX·RGB + 프리셋·최근색)를 연다.

핵심은 견본이 레거시 원문 값을 그대로 넣는다는 점이다. 명명색을 RGB 로 풀면
원문과 달라져 왕복 diff 가 생기고, 시스템색이 갖는 의미(Window/Control/ControlText)도 잃는다.
'Window' 를 고르면 "Window" 가 저장된다. 툴팁에는 실사용 건수를 함께 띄운다.

'비우기'는 기본값을 저장하는 것이 아니라 키를 지운다 — 레거시 PropertyGrid 의 재설정과
같고, 원래 BackColor 가 없던 페이지(12.7%)를 원상태로 되돌릴 수 있어야 한다.

함께 고친 것: 페이지 속성 모드에서 '컨트롤을 선택하면 속성이 표시됩니다' 안내가 행과 같이
뜨던 것(HasSelection 이 컨트롤 선택만 보고 있었다). 탭 스트립은 페이지 모드에서 숨긴다.

편집 스모크 4건 추가(원문 값 적용 / 명명색 보존 / 적용 후 닫힘 / 비우기는 키 제거).
회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 10:56:47 +09:00
co-authored by Claude Opus 5
parent ac36f93a03
commit e84337705f
7 changed files with 337 additions and 21 deletions
@@ -0,0 +1,52 @@
namespace SheetMe.Core.Catalog;
/// <summary>
/// 색상 행 인라인 팔레트 — 운영 서식에서 <b>실제로 쓰이는</b> 값만 담는다.
///
/// 표준 색상표(빨/주/노/초…)를 늘어놓지 않는 이유가 있다. 실측(운영 디자인 1,271건의
/// BackColor·ForeColor 60,319건)에서 상위 두 값이 전체의 84%를 차지한다 —
/// White 29,010건(48%)과 "224, 224, 224" 21,497건(36%)이다. 뒤쪽은 꼬리가 길고 얇다.
/// 자주 쓰는 값을 한 번에 고르게 하는 것이 목적이지 색을 발명하게 하는 것이 아니다.
///
/// 값은 <b>레거시 원문 형식</b>을 그대로 쓴다(명명색 또는 "R, G, B").
/// 명명색을 RGB 로 풀면 원문과 달라져 왕복 diff 가 생기고, 시스템색의 의미도 사라진다.
/// </summary>
public static class LegacyColorCatalog
{
/// <summary>팔레트 항목 — 저장값과 실사용 건수(툴팁 표시용)</summary>
public sealed record Entry(string Value, string Usage);
/// <summary>배경색 계열 — 실측 상위순</summary>
public static IReadOnlyList<Entry> 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건"),
};
/// <summary>글자색·강조 계열 — 실측 상위순</summary>
public static IReadOnlyList<Entry> 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건"),
};
}
+5
View File
@@ -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]);
@@ -227,6 +227,84 @@ public static class DbSmoke
return 0;
}
/// <summary>
/// 색 속성 값 전수 집계 — 색상 행의 인라인 팔레트를 실제 사용값으로 채우기 위한 근거.
/// 레거시는 명명색(White/Window/Control…)과 "R, G, B" 를 섞어 쓴다.
/// </summary>
public static int RunColorReport(string reportPath)
{
var lines = new List<string>();
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<string, Dictionary<string, int>>(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<string, int>(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
@@ -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]);
@@ -39,7 +39,11 @@ public sealed class InspectorViewModel : ViewModelBase
public ObservableCollection<PropertyRowViewModel> Rows { get; } = new();
/// <summary>선택된 컨트롤이 있는지 — 인스펙터 빈 상태 판정</summary>
public bool HasSelection => designer.Selection.Items.Count > 0;
/// <summary>
/// 보여 줄 대상이 있는가 — 컨트롤 선택 또는 페이지 속성 모드.
/// 페이지도 레거시에선 하나의 Object 라 '선택 없음' 안내를 띄우면 안 된다.
/// </summary>
public bool HasSelection => designer.Selection.Items.Count > 0 || designer.InspectedPage is not null;
/// <summary>
/// 선택된 탭. 컨트롤을 바꿔도 유지된다 — 데이터 탭에서 여러 컨트롤의 태그를 잇달아 손보는
@@ -104,8 +108,9 @@ public sealed class InspectorViewModel : ViewModelBase
/// <summary>동작 탭을 그릴지 — 이 컨트롤에 동작 속성이 있을 때만</summary>
public bool ShowBehaviorTab => BehaviorCount > 0;
/// <summary>탭이 하나뿐이면 알약 줄 자체가 잡음이다 — 둘 이상일 때만 띄운다</summary>
public bool ShowTabStrip => HasSelection && (ShowDataTab || ShowBehaviorTab);
/// <summary>탭이 하나뿐이면 알약 줄 자체가 잡음이다 — 둘 이상일 때만 띄운다.
/// 페이지 속성 모드에는 데이터·동작 탭이 없으므로 컨트롤 선택일 때만 본다.</summary>
public bool ShowTabStrip => designer.Selection.Items.Count > 0 && (ShowDataTab || ShowBehaviorTab);
/// <summary>선택은 있는데 이 탭에 보여줄 게 없는 상태 — 빈 화면 대신 안내를 띄운다</summary>
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 를 명시하지 않은 컨트롤이 전부 따라온다
@@ -512,6 +512,8 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
/// <summary>색 행 — 레거시 invariant 문자열("R, G, B"/명명색) + 미리보기 스와치</summary>
public sealed class ColorRowViewModel : PropertyRowViewModel
{
private bool isPaletteOpen;
/// <summary>미리보기 브러시</summary>
public Brush Preview
{
@@ -531,9 +533,47 @@ public sealed class ColorRowViewModel : PropertyRowViewModel
/// <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) => 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
}
}
/// <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
{
+81 -16
View File
@@ -424,26 +424,91 @@
</StackPanel>
</DataTemplate>
<!-- 팔레트 견본 하나 — 윤곽은 테마색이 아니라 견본 자신의 명도로 정한다
(테마 테두리를 쓰면 라이트에서 흰 견본이, 다크에서 검정 견본이 빈칸으로 보인다) -->
<DataTemplate x:Key="ColorSwatchTemplate" DataType="{x:Type ins:ColorSwatchViewModel}">
<Button Width="22" Height="22" Margin="0,0,4,4" Padding="0" Cursor="Hand"
ToolTip="{Binding ToolTip}"
Command="{Binding DataContext.ApplySwatchCommand,
RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="sw" Background="{Binding Preview}" BorderBrush="{Binding Edge}"
BorderThickness="1" CornerRadius="4" SnapsToDevicePixels="True"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="sw" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
<Setter TargetName="sw" Property="BorderThickness" Value="2"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:ColorRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"
ToolTip="{Binding LabelToolTip}"/>
<!-- 스와치 = 피커 버튼(클릭 → 색상 선택 대화상자), 직접 입력도 병행 -->
<Button DockPanel.Dock="Right" Width="30" Height="26" Margin="4,0,0,0" Padding="0"
Command="{Binding PickCommand}" Cursor="Hand"
ToolTip="클릭하여 색상 선택">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{Binding Preview}" BorderBrush="{DynamicResource B.Line2}"
BorderThickness="1" CornerRadius="4" SnapsToDevicePixels="True"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<!--
스와치 = 인라인 팔레트 토글. 실측상 배경색의 84%가 White 와 "224, 224, 224"
단 두 값이라, 그 자리에서 바로 고르는 편이 대화상자를 여는 것보다 빠르다.
임의 색은 팔레트 아래 '자세히…' 로 기존 피커(SV/Hue/HEX/RGB)를 연다.
-->
<Grid DockPanel.Dock="Right" Margin="4,0,0,0">
<ToggleButton x:Name="SwatchToggle" Width="30" Height="26" Padding="0" Cursor="Hand"
IsChecked="{Binding IsPaletteOpen, Mode=TwoWay}"
ToolTip="클릭하여 색 고르기">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="bd" Background="{Binding Preview}" BorderBrush="{DynamicResource B.Line2}"
BorderThickness="1" CornerRadius="4" SnapsToDevicePixels="True"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<Popup IsOpen="{Binding IsPaletteOpen, Mode=TwoWay}" StaysOpen="False" AllowsTransparency="True"
PlacementTarget="{Binding ElementName=SwatchToggle}" Placement="Bottom"
HorizontalOffset="-176" VerticalOffset="4">
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line2}"
BorderThickness="1" CornerRadius="8" Padding="10" Effect="{DynamicResource B.FloatShadow}">
<StackPanel Width="212">
<TextBlock Text="배경" FontSize="11" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,5"/>
<ItemsControl ItemsSource="{Binding BackgroundSwatches}"
ItemTemplate="{StaticResource ColorSwatchTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBlock Text="글자·강조" FontSize="11" Foreground="{DynamicResource B.Muted}" Margin="0,8,0,5"/>
<ItemsControl ItemsSource="{Binding ForegroundSwatches}"
ItemTemplate="{StaticResource ColorSwatchTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Border Height="1" Background="{DynamicResource B.Line}" Margin="0,9,0,8"/>
<DockPanel>
<Button DockPanel.Dock="Right" Content="자세히…" Height="28" Padding="10,0"
Command="{Binding PickCommand}"
ToolTip="색상 피커 — 채도·명도·색상환, HEX·RGB 직접 입력"/>
<Button HorizontalAlignment="Left" Content="비우기" Height="28" Padding="10,0"
Style="{StaticResource Subtle}" Command="{Binding ClearCommand}"
ToolTip="속성값을 지웁니다(기본값·상속으로 되돌림)"/>
</DockPanel>
</StackPanel>
</Border>
</Popup>
</Grid>
<Grid>
<TextBox Style="{StaticResource RowInput}" bh:InspectorFieldBehavior.CommitOnEnter="True" Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
ToolTip="R, G, B 또는 색 이름 (예: 224, 224, 224 / White) — 오른쪽 스와치 클릭 시 피커"/>