Files
SheetMe/src/SheetMe.Designer/Views/ColorPickerWindow.cs
T
MsystechandClaude Opus 5 308197c15e 별도 창 전수 점검 — 암시 Window 스타일이 파생 클래스에 적용된 적이 없었다
사용자 지적: "다른 창으로(새창으로 열리는) 실행하는 항목들도 모두 점검해줘".

[근본 원인 — 대화상자 전체가 다크에서 흰 창이었다]
WPF 는 암시 스타일을 요소의 **정확한 런타임 타입**으로만 찾는다. 저장소의 모든 창이
Window 를 상속한 클래스(SheetOpenDialogView : Window, QueryEditorWindow : Window …)라
DesignerTheme.xaml 의 <Style TargetType="Window"> 는 **단 한 번도 적용된 적이 없다**.
결과: 창 배경이 WPF 기본 흰색으로 남고, 그 위 글자는 암시 TextBlock 스타일(B.Ink #EDEDED)을
정상적으로 받는다 → **다크 테마에서 흰 바탕에 흰 글자**. 쿼리 편집기의 변수 목록처럼 라벨이
통째로 사라지는 화면이 나왔다.

라이트에서 눈에 덜 띈 이유는 우연이다 — 기본 흰색(#FFFFFF)이 의도값 B.AppBg(#F5F5F5)와
거의 같아서다. 그래서 지금까지 라이트만 보고는 발견되지 않았다.

수정: 스타일에 x:Key="ThemedWindow" 를 주고 창 10종에 직접 걸었다(코드로 짓는
ColorPickerWindow 는 생성자에서 TryFindResource). 순수 Window 인스턴스용 암시 스타일은
BasedOn 으로 남겼다. 앞으로 창을 추가할 때 잊지 않도록 스타일 위에 경고 주석을 붙였다.

[신규 진단 --dialog-shots <출력폴더>]
이 결함은 정적 분석으로는 못 잡았다(앞선 감사에서 "대화상자들은 암시 Window 스타일을 그대로
받는다"고 잘못 결론냈다). 실입력 주입은 화면 잠금·세션 격리 상태에서 OS 가 거부한다
(SetCursorPos 무시, SendKeys "Access is denied" — 이번에도 중간부터 막혔다).
그래서 창을 화면 밖(-10000)에 띄워 RenderTargetBitmap 으로 찍는 진단을 만들었다.
창 8종 × 2테마 = 16장을 한 번에 남기고, 각 창의 **해석된 Background 와 Style 적용 여부를
텍스트로 함께 보고**한다 — 픽셀만 보면 원인을 못 가린다(실제로 이 한 줄이 원인을 확정했다).
표본 데이터는 실제 사용 시와 같은 형태로 넣었다(빈 껍데기를 찍으면 의미가 없다).

함정 2개를 코드에 남겼다.
· 기본 ShutdownMode 가 OnLastWindowClose 라 찍고 닫는 순간 앱이 종료된다 → OnExplicitShutdown.
· Window.Content 만 렌더하면 창 배경이 빠져 투명(=PNG 검정)이 되고, 다크처럼 보이는 착시로
  라이트 결함이 가려진다 → 배경을 먼저 칠하고 그 위에 콘텐츠를 그린다.

[함께 고친 것 — 여러 줄 입력이 세로 가운데 정렬]
쿼리 편집기의 SQL 이 큰 상자 한가운데 떠 있었다. 템플릿 트리거가 ScrollViewer 의
VerticalContentAlignment 만 Stretch 로 바꿨는데, 텍스트를 배치하는 건 TextBox 자신의
VerticalContentAlignment 이라 아무 효과가 없었다. Style.Triggers 로 옮겨 Top 으로 두고
여러 줄일 때 Padding 도 넉넉히 준다. 인스펙터의 여러 줄 속성 행에도 함께 적용된다.

검증: 창 8종 × 2테마 전부 재렌더해 배경 확인(다크 #0E0E0E / 라이트 #F5F5F5,
미리보기만 의도대로 B.CanvasBg). 테스트 124/124, edit-smoke 실패 0,
**종이 렌더 P062 픽셀 대조 차이 0**.

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

363 lines
15 KiB
C#

using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace SheetMe.Designer.Views;
/// <summary>
/// Figma 식 색상 피커([200]SheetMe ColorPickerWindow 이식) — 채도/명도(SV) 사각형 + 색상(Hue) 슬라이더
/// + HEX·RGB 입력 + 프리셋/최근색. 정적 <see cref="Pick"/> 호출: 확정 시 "#RRGGBB" 반환, 취소 시 null.
/// 셸 색은 앱 테마 토큰(B.*) — SV/Hue/스와치는 색상 원본 그대로.
/// </summary>
public sealed class ColorPickerWindow : Window
{
#region Member Fields
private static readonly List<string> recent = new();
private double hue360; // H 0..360
private double sat; // S 0..1
private double val; // V 0..1
private bool syncing; // 텍스트박스 ↔ 상태 갱신 재진입 방지
private readonly Canvas svArea = new() { Width = 248, Height = 168, ClipToBounds = true, Cursor = Cursors.Cross };
private readonly Rectangle svHue = new() { Width = 248, Height = 168 };
private readonly Ellipse svThumb = new() { Width = 14, Height = 14, Stroke = Brushes.White, StrokeThickness = 2, IsHitTestVisible = false };
private readonly Canvas hueArea = new() { Width = 248, Height = 16, ClipToBounds = true, Cursor = Cursors.Cross };
private readonly Border hueThumb = new() { Width = 6, Height = 20, BorderBrush = Brushes.White, BorderThickness = new Thickness(2), CornerRadius = new CornerRadius(2), IsHitTestVisible = false };
private readonly Border preview = new() { Width = 40, Height = 40, CornerRadius = new CornerRadius(6), BorderThickness = new Thickness(1) };
private readonly TextBox hexBox = new() { Width = 92, VerticalContentAlignment = VerticalAlignment.Center };
private readonly TextBox rBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
private readonly TextBox gBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
private readonly TextBox bBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
#endregion
#region Properties
/// <summary>확정된 "#RRGGBB"(취소 시 null)</summary>
public string? Result { get; private set; }
#endregion
#region Methods
/// <summary>색상 선택 대화상자 — 확정 시 "#RRGGBB", 취소 시 null. initialHex 로 초기색 지정.</summary>
public static string? Pick(Window? owner, string? initialHex)
{
var window = new ColorPickerWindow(initialHex);
if (owner is not null)
{
window.Owner = owner;
}
return window.ShowDialog() == true ? window.Result : null;
}
private ColorPickerWindow(string? initialHex)
{
Title = "색상 선택";
// 암시 Window 스타일은 파생 클래스에 적용되지 않는다 — 직접 걸지 않으면 다크에서 흰 창이 된다
if (Application.Current?.TryFindResource("ThemedWindow") is Style themed)
{
Style = themed;
}
Width = 300;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
ShowInTaskbar = false;
FontSize = 12.5;
var line = FindBrush("B.Line");
var muted = FindBrush("B.Muted");
preview.BorderBrush = line;
var initial = ParseHex(initialHex) ?? Color.FromRgb(0x2F, 0x6D, 0xF0);
(hue360, sat, val) = RgbToHsv(initial.R, initial.G, initial.B);
var root = new StackPanel { Margin = new Thickness(14) };
// ── SV 사각형(흰색→hue 가로 + 투명→검정 세로) ──
svArea.Children.Add(svHue);
svArea.Children.Add(new Rectangle
{
Width = 248, Height = 168,
Fill = new LinearGradientBrush(Color.FromArgb(255, 255, 255, 255), Color.FromArgb(0, 255, 255, 255), new Point(0, 0), new Point(1, 0)),
});
svArea.Children.Add(new Rectangle
{
Width = 248, Height = 168,
Fill = new LinearGradientBrush(Color.FromArgb(0, 0, 0, 0), Color.FromArgb(255, 0, 0, 0), new Point(0, 0), new Point(0, 1)),
});
svArea.Children.Add(svThumb);
svArea.MouseLeftButtonDown += (_, e) => { svArea.CaptureMouse(); UpdateSvFrom(e.GetPosition(svArea)); };
svArea.MouseMove += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed && svArea.IsMouseCaptured) UpdateSvFrom(e.GetPosition(svArea)); };
svArea.MouseLeftButtonUp += (_, _) => svArea.ReleaseMouseCapture();
root.Children.Add(new Border { Child = svArea, CornerRadius = new CornerRadius(6), ClipToBounds = true, Margin = new Thickness(0, 0, 0, 10) });
// ── Hue 슬라이더(무지개) ──
hueArea.Children.Add(new Rectangle
{
Width = 248, Height = 16,
Fill = new LinearGradientBrush(new GradientStopCollection
{
new(Color.FromRgb(255, 0, 0), 0), new(Color.FromRgb(255, 255, 0), 1 / 6.0), new(Color.FromRgb(0, 255, 0), 2 / 6.0),
new(Color.FromRgb(0, 255, 255), 3 / 6.0), new(Color.FromRgb(0, 0, 255), 4 / 6.0), new(Color.FromRgb(255, 0, 255), 5 / 6.0), new(Color.FromRgb(255, 0, 0), 1),
}, new Point(0, 0), new Point(1, 0)),
RadiusX = 8, RadiusY = 8,
});
hueArea.Children.Add(hueThumb);
Canvas.SetTop(hueThumb, -2);
hueArea.MouseLeftButtonDown += (_, e) => { hueArea.CaptureMouse(); UpdateHueFrom(e.GetPosition(hueArea)); };
hueArea.MouseMove += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed && hueArea.IsMouseCaptured) UpdateHueFrom(e.GetPosition(hueArea)); };
hueArea.MouseLeftButtonUp += (_, _) => hueArea.ReleaseMouseCapture();
root.Children.Add(new Border { Child = hueArea, Margin = new Thickness(0, 0, 0, 12) });
// ── 미리보기 + HEX / RGB ──
var inputs = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 12) };
inputs.Children.Add(preview);
var fields = new StackPanel { Margin = new Thickness(12, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
var hexRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
hexRow.Children.Add(new TextBlock { Text = "HEX", Width = 30, VerticalAlignment = VerticalAlignment.Center, Foreground = muted });
hexRow.Children.Add(hexBox);
fields.Children.Add(hexRow);
var rgbRow = new StackPanel { Orientation = Orientation.Horizontal };
rgbRow.Children.Add(new TextBlock { Text = "RGB", Width = 30, VerticalAlignment = VerticalAlignment.Center, Foreground = muted });
rgbRow.Children.Add(rBox);
rgbRow.Children.Add(new Border { Width = 4 });
rgbRow.Children.Add(gBox);
rgbRow.Children.Add(new Border { Width = 4 });
rgbRow.Children.Add(bBox);
fields.Children.Add(rgbRow);
inputs.Children.Add(fields);
root.Children.Add(inputs);
hexBox.LostKeyboardFocus += (_, _) => CommitHex();
hexBox.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitHex(); };
foreach (var box in new[] { rBox, gBox, bBox })
{
box.LostKeyboardFocus += (_, _) => CommitRgb();
box.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitRgb(); };
}
// ── 프리셋 + 최근색 ──
root.Children.Add(SwatchGrid("프리셋", new[]
{
"#000000", "#374151", "#6B727B", "#C7CDD4", "#FFFFFF", "#C0392B", "#E67E22", "#F1C40F",
"#1F9D55", "#16A085", "#2F6DF0", "#2980B9", "#8E44AD", "#EC4899", "#7F1D1D", "#FDE68A",
}));
if (recent.Count > 0)
{
root.Children.Add(SwatchGrid("최근 사용", recent.ToArray()));
}
// ── 버튼 ──
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
var ok = new Button { Content = "적용", MinWidth = 64, Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
try { ok.Style = (Style)FindResource("Primary"); } catch { /* 테마 미로드 시 기본 */ }
ok.Click += (_, _) => { Result = CurrentHex(); AddRecent(Result); DialogResult = true; };
var cancel = new Button { Content = "취소", MinWidth = 64, IsCancel = true };
cancel.Click += (_, _) => DialogResult = false;
buttons.Children.Add(ok);
buttons.Children.Add(cancel);
root.Children.Add(buttons);
Content = root;
RefreshAll();
}
private FrameworkElement SwatchGrid(string title, string[] hexes)
{
var section = new StackPanel { Margin = new Thickness(0, 2, 0, 0) };
section.Children.Add(new TextBlock { Text = title, FontSize = 11, Foreground = FindBrush("B.Muted"), Margin = new Thickness(0, 0, 0, 4) });
var wrap = new WrapPanel();
foreach (var hex in hexes)
{
var swatch = new Button
{
Width = 22, Height = 22, Margin = new Thickness(0, 0, 4, 4), Padding = new Thickness(0),
ToolTip = hex,
Background = new SolidColorBrush(ParseHex(hex) ?? Colors.White),
};
// 견본 테두리는 테마색이 아니라 견본 자신의 명도에서 정한다 — 테마 테두리를 쓰면
// 라이트에서 흰 견본이, 다크에서 검정 견본이 배경에 묻혀 빈칸으로 보인다.
swatch.BorderBrush = ContrastEdge(ParseHex(hex) ?? Colors.White);
var captured = hex;
swatch.Click += (_, _) =>
{
if (ParseHex(captured) is not { } color)
{
return;
}
(hue360, sat, val) = RgbToHsv(color.R, color.G, color.B);
RefreshAll();
};
wrap.Children.Add(swatch);
}
section.Children.Add(wrap);
return section;
}
private static Brush FindBrush(string key)
=> Application.Current.TryFindResource(key) as Brush ?? Brushes.Gray;
/// <summary>
/// 색 견본의 윤곽선 — 견본 자신의 밝기에 따라 어두운/밝은 테두리를 고른다.
/// 테마 토큰(B.Line2)을 쓰면 라이트에서 흰 견본이, 다크에서 검정 견본이 배경에 묻혀
/// 빈칸으로 보인다. 견본은 색 자체가 데이터라 배경이 아니라 견본 기준으로 판단해야 한다.
/// </summary>
private static Brush ContrastEdge(Color color)
{
// WCAG 상대휘도의 단순 근사 — 견본 테두리 판정에는 이 정도면 충분하다
var luminance = (0.2126 * color.R + 0.7152 * color.G + 0.0722 * color.B) / 255.0;
var brush = new SolidColorBrush(luminance > 0.6
? Color.FromRgb(0x8A, 0x8A, 0x8A)
: Color.FromRgb(0xD0, 0xD0, 0xD0));
brush.Freeze();
return brush;
}
// ── 상호작용 → 상태 ──
private void UpdateSvFrom(Point point)
{
sat = Math.Clamp(point.X / svArea.Width, 0, 1);
val = 1 - Math.Clamp(point.Y / svArea.Height, 0, 1);
RefreshAll();
}
private void UpdateHueFrom(Point point)
{
hue360 = Math.Clamp(point.X / hueArea.Width, 0, 1) * 360;
RefreshAll();
}
private void CommitHex()
{
if (syncing)
{
return;
}
if (ParseHex(hexBox.Text) is not { } color)
{
RefreshAll();
return;
}
(hue360, sat, val) = RgbToHsv(color.R, color.G, color.B);
RefreshAll();
}
private void CommitRgb()
{
if (syncing)
{
return;
}
if (byte.TryParse(rBox.Text, out var r) && byte.TryParse(gBox.Text, out var g) && byte.TryParse(bBox.Text, out var b))
{
(hue360, sat, val) = RgbToHsv(r, g, b);
}
RefreshAll();
}
// ── 상태 → 화면 ──
private void RefreshAll()
{
var (hueR, hueG, hueB) = HsvToRgb(hue360, 1, 1);
svHue.Fill = new SolidColorBrush(Color.FromRgb(hueR, hueG, hueB));
Canvas.SetLeft(svThumb, sat * svArea.Width - 7);
Canvas.SetTop(svThumb, (1 - val) * svArea.Height - 7);
var (r, g, b) = HsvToRgb(hue360, sat, val);
svThumb.Fill = new SolidColorBrush(Color.FromRgb(r, g, b));
Canvas.SetLeft(hueThumb, hue360 / 360 * hueArea.Width - 3);
preview.Background = new SolidColorBrush(Color.FromRgb(r, g, b));
syncing = true;
hexBox.Text = CurrentHex();
rBox.Text = r.ToString();
gBox.Text = g.ToString();
bBox.Text = b.ToString();
syncing = false;
}
private string CurrentHex()
{
var (r, g, b) = HsvToRgb(hue360, sat, val);
return $"#{r:X2}{g:X2}{b:X2}";
}
private static void AddRecent(string? hex)
{
if (string.IsNullOrEmpty(hex))
{
return;
}
recent.Remove(hex);
recent.Insert(0, hex);
while (recent.Count > 16)
{
recent.RemoveAt(recent.Count - 1);
}
}
// ── 색 변환 ──
private static Color? ParseHex(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
var s = text.Trim().TrimStart('#');
if (s.Length == 3)
{
s = $"{s[0]}{s[0]}{s[1]}{s[1]}{s[2]}{s[2]}";
}
if (s.Length != 6)
{
return null;
}
return int.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
? Color.FromRgb((byte)((value >> 16) & 0xFF), (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF))
: null;
}
private static (double H, double S, double V) RgbToHsv(byte r, byte g, byte b)
{
double rd = r / 255.0, gd = g / 255.0, bd = b / 255.0;
double max = Math.Max(rd, Math.Max(gd, bd)), min = Math.Min(rd, Math.Min(gd, bd));
double d = max - min, h = 0;
if (d != 0)
{
if (max == rd)
{
h = 60 * (((gd - bd) / d) % 6);
}
else if (max == gd)
{
h = 60 * (((bd - rd) / d) + 2);
}
else
{
h = 60 * (((rd - gd) / d) + 4);
}
}
if (h < 0)
{
h += 360;
}
return (h, max == 0 ? 0 : d / max, max);
}
private static (byte R, byte G, byte B) HsvToRgb(double h, double s, double v)
{
double c = v * s, x = c * (1 - Math.Abs((h / 60 % 2) - 1)), m = v - c;
double r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
return ((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255));
}
#endregion
}