초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)
레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너. 기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49. 이 커밋에 함께 포함된 자격증명 분리: - appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환 - 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외, csproj Debug 조건부 복사라 Release 산출물에 실리지 않음) - ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경, 미치환 플레이스홀더는 '미설정'으로 간주 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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 = "색상 선택";
|
||||
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, BorderBrush = FindBrush("B.Line2"),
|
||||
Background = new SolidColorBrush(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;
|
||||
|
||||
// ── 상호작용 → 상태 ──
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user