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; /// /// Figma 식 색상 피커([200]SheetMe ColorPickerWindow 이식) — 채도/명도(SV) 사각형 + 색상(Hue) 슬라이더 /// + HEX·RGB 입력 + 프리셋/최근색. 정적 호출: 확정 시 "#RRGGBB" 반환, 취소 시 null. /// 셸 색은 앱 테마 토큰(B.*) — SV/Hue/스와치는 색상 원본 그대로. /// public sealed class ColorPickerWindow : Window { #region Member Fields private static readonly List 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 /// 확정된 "#RRGGBB"(취소 시 null) public string? Result { get; private set; } #endregion #region Methods /// 색상 선택 대화상자 — 확정 시 "#RRGGBB", 취소 시 null. initialHex 로 초기색 지정. 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; /// /// 색 견본의 윤곽선 — 견본 자신의 밝기에 따라 어두운/밝은 테두리를 고른다. /// 테마 토큰(B.Line2)을 쓰면 라이트에서 흰 견본이, 다크에서 검정 견본이 배경에 묻혀 /// 빈칸으로 보인다. 견본은 색 자체가 데이터라 배경이 아니라 견본 기준으로 판단해야 한다. /// 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 }