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

색을 바꾸려면 매번 대화상자를 열어야 했다. 실측해 보니 그럴 이유가 없다 —
--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
+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) — 오른쪽 스와치 클릭 시 피커"/>