사용자 지적: "다른 창으로(새창으로 열리는) 실행하는 항목들도 모두 점검해줘". [근본 원인 — 대화상자 전체가 다크에서 흰 창이었다] 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>
363 lines
15 KiB
C#
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
|
|
}
|