374 lines
17 KiB
C#
374 lines
17 KiB
C#
using System.Drawing;
|
||
using System.Drawing.Drawing2D;
|
||
using System.Drawing.Text;
|
||
using System.Runtime.InteropServices;
|
||
using System.Windows.Forms;
|
||
|
||
namespace AxCopilot.Views;
|
||
|
||
/// <summary>
|
||
/// 모던 카드 스타일 트레이 컨텍스트 메뉴 팩토리.
|
||
/// 배경색은 현재 WPF 테마 리소스를 실시간으로 읽어 적용합니다.
|
||
/// </summary>
|
||
internal static class TrayContextMenu
|
||
{
|
||
// Segoe MDL2 Assets 글리프 상수
|
||
internal const string GlyphOpen = "\uE7C5"; // Launch
|
||
internal const string GlyphSettings = "\uE713"; // Settings
|
||
internal const string GlyphReload = "\uE72C"; // Refresh
|
||
internal const string GlyphFolder = "\uE838"; // Folder
|
||
internal const string GlyphInfo = "\uE946"; // Info
|
||
internal const string GlyphStats = "\uE9D9"; // Chart
|
||
internal const string GlyphChat = "\uE8BD"; // Chat
|
||
internal const string GlyphGuide = "\uE736"; // ReadingList (사용 가이드)
|
||
internal const string GlyphAutoRun = "\uE82F"; // Lightbulb (전구)
|
||
internal const string GlyphExit = "\uE711"; // Cancel
|
||
|
||
// 1×1 투명 더미 이미지 — OnRenderItemImage 콜백을 강제로 트리거하기 위함
|
||
private static readonly System.Drawing.Bitmap DummyImage = new(1, 1);
|
||
|
||
/// <summary>
|
||
/// 현재 화면의 DPI 배율을 반환합니다 (100% = 1.0, 150% = 1.5).
|
||
/// WinForms는 논리 픽셀 값을 DPI 배율만큼 자동 확대하므로,
|
||
/// 이 값으로 역산하여 모든 PC에서 동일한 물리적 크기를 유지합니다.
|
||
/// </summary>
|
||
private static float GetDpiScale()
|
||
{
|
||
try
|
||
{
|
||
using var g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero);
|
||
return Math.Max(1f, g.DpiX / 96f);
|
||
}
|
||
catch { return 1f; }
|
||
}
|
||
|
||
/// <summary>논리 픽셀 값을 DPI 역산하여 물리적 크기가 동일하게 유지되도록 변환합니다.</summary>
|
||
private static int Dp(int logicalPx, float dpiScale) =>
|
||
Math.Max(1, (int)Math.Round(logicalPx / dpiScale));
|
||
|
||
/// <summary>외부에서 DPI 보정된 Padding을 생성할 때 사용합니다.</summary>
|
||
public static System.Windows.Forms.Padding DpiPadding(int left, int top, int right, int bottom)
|
||
{
|
||
var s = GetDpiScale();
|
||
return new System.Windows.Forms.Padding(Dp(left, s), Dp(top, s), Dp(right, s), Dp(bottom, s));
|
||
}
|
||
|
||
public static ToolStripMenuItem MakeItem(string text, string glyph, EventHandler onClick)
|
||
{
|
||
var s = GetDpiScale();
|
||
var item = new ToolStripMenuItem(text)
|
||
{
|
||
Tag = glyph,
|
||
Image = DummyImage, // Image가 있어야 OnRenderItemImage가 호출됨
|
||
Padding = new Padding(Dp(4, s), Dp(10, s), Dp(16, s), Dp(10, s)),
|
||
};
|
||
item.Click += onClick;
|
||
return item;
|
||
}
|
||
|
||
/// <summary>패딩이 적용된 ContextMenuStrip을 생성합니다.</summary>
|
||
public static ContextMenuStrip CreateMenu()
|
||
{
|
||
var s = GetDpiScale();
|
||
var menu = new ContextMenuStrip();
|
||
menu.Renderer = new ModernTrayRenderer();
|
||
// Point 단위 폰트는 DPI 자동 확대되므로 역산 적용
|
||
// Point 단위는 DPI와 무관하게 동일한 물리 크기 → 역산하지 않음
|
||
menu.Font = new Font("Segoe UI", 10f, GraphicsUnit.Point);
|
||
menu.ShowImageMargin = true;
|
||
// 너비를 넉넉히 잡아 좌측 아이콘~테두리 간 여백 확보
|
||
menu.ImageScalingSize = new Size(Dp(52, s), Dp(32, s));
|
||
menu.MinimumSize = new Size(Dp(280, s), 0);
|
||
|
||
// 팝업이 열릴 때 모서리를 둥글게 클리핑
|
||
menu.Opened += (_, _) => ApplyRoundedCorners(menu);
|
||
return menu;
|
||
}
|
||
|
||
private static void ApplyRoundedCorners(ContextMenuStrip menu)
|
||
{
|
||
try
|
||
{
|
||
var rgn = CreateRoundRectRgn(0, 0, menu.Width, menu.Height, 16, 16);
|
||
if (rgn == IntPtr.Zero) return;
|
||
// SetWindowRgn 성공 시 시스템이 핸들 소유권을 가져감 → DeleteObject 불필요
|
||
// 실패 시 직접 해제해야 GDI 누수 방지
|
||
if (SetWindowRgn(menu.Handle, rgn, true) == 0)
|
||
DeleteObject(rgn);
|
||
}
|
||
catch { /* 클리핑 실패 시 사각형으로 폴백 */ }
|
||
}
|
||
|
||
[DllImport("gdi32.dll")]
|
||
private static extern IntPtr CreateRoundRectRgn(int x1, int y1, int x2, int y2, int cx, int cy);
|
||
|
||
[DllImport("gdi32.dll")]
|
||
private static extern bool DeleteObject(IntPtr hObject);
|
||
|
||
[DllImport("user32.dll")]
|
||
private static extern int SetWindowRgn(IntPtr hWnd, IntPtr hRgn, bool bRedraw);
|
||
|
||
/// <summary>
|
||
/// 모든 아이템 추가 후 호출하여 상하 테두리 여백을 적용합니다.
|
||
/// WinForms ContextMenuStrip은 Padding/DefaultPadding/DisplayRectangle 모두
|
||
/// 내부 레이아웃이 무시하므로, 첫/마지막 아이템의 Margin으로 처리합니다.
|
||
/// </summary>
|
||
public static void ApplySpacing(ContextMenuStrip menu, int top = 10, int bottom = 10, int left = 0, int right = 0)
|
||
{
|
||
if (menu.Items.Count == 0) return;
|
||
var s = GetDpiScale();
|
||
int t = Dp(top, s), b = Dp(bottom, s), l = Dp(left, s), r = Dp(right, s);
|
||
|
||
foreach (ToolStripItem item in menu.Items)
|
||
{
|
||
var m = item.Margin;
|
||
item.Margin = new Padding(m.Left + l, m.Top, m.Right + r, m.Bottom);
|
||
}
|
||
var first = menu.Items[0];
|
||
first.Margin = new Padding(first.Margin.Left, t, first.Margin.Right, first.Margin.Bottom);
|
||
var last = menu.Items[menu.Items.Count - 1];
|
||
last.Margin = new Padding(last.Margin.Left, last.Margin.Top, last.Margin.Right, b);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
internal sealed class ModernTrayRenderer : ToolStripProfessionalRenderer
|
||
{
|
||
// 전구(자동 실행) 활성 색상 — 앰버 글로우
|
||
private static readonly Color BulbOnColor = Color.FromArgb(255, 185, 0);
|
||
private static readonly Color BulbGlowHalo = Color.FromArgb(50, 255, 185, 0);
|
||
|
||
public ModernTrayRenderer() : base(new ModernColorTable()) { }
|
||
|
||
// ─── 테마 색상 리더 ─────────────────────────────────────────────────
|
||
|
||
private static Color ThemeColor(string key, Color fallback)
|
||
{
|
||
try
|
||
{
|
||
if (System.Windows.Application.Current?.Resources[key]
|
||
is System.Windows.Media.SolidColorBrush b)
|
||
return Color.FromArgb(b.Color.A, b.Color.R, b.Color.G, b.Color.B);
|
||
}
|
||
catch { }
|
||
return fallback;
|
||
}
|
||
|
||
private static Color BgColor => ThemeColor("LauncherBackground", Color.FromArgb(250, 250, 252));
|
||
private static Color HoverColor => ThemeColor("ItemHoverBackground", Color.FromArgb(234, 234, 247));
|
||
private static Color AccentColor => ThemeColor("AccentColor", Color.FromArgb(75, 94, 252));
|
||
private static Color TextColor => ThemeColor("PrimaryText", Color.FromArgb(22, 23, 42));
|
||
private static Color TextDimColor => ThemeColor("SecondaryText", Color.FromArgb(90, 92, 128));
|
||
private static Color SepColor => ThemeColor("SeparatorColor", Color.FromArgb(228, 228, 242));
|
||
private static Color BorderColor => ThemeColor("BorderColor", Color.FromArgb(210, 210, 232));
|
||
private static Color IconColor => ThemeColor("SecondaryText", Color.FromArgb(110, 112, 148));
|
||
|
||
// ─── 배경 ────────────────────────────────────────────────────────────
|
||
|
||
protected override void OnRenderToolStripBackground(ToolStripRenderEventArgs e)
|
||
{
|
||
using var br = new SolidBrush(BgColor);
|
||
e.Graphics.FillRectangle(br, e.AffectedBounds);
|
||
}
|
||
|
||
protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e)
|
||
{
|
||
var g = e.Graphics;
|
||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||
using var pen = new Pen(BorderColor, 1f);
|
||
// ApplyRoundedCorners 의 clip radius(14) 보다 0.5px 안쪽에 그려야
|
||
// 창 경계 밖으로 삐져나오지 않음
|
||
var rect = new RectangleF(0.5f, 0.5f,
|
||
e.ToolStrip.Width - 1f,
|
||
e.ToolStrip.Height - 1f);
|
||
using var path = BuildRoundedPath(rect, 15f);
|
||
g.DrawPath(pen, path);
|
||
g.SmoothingMode = SmoothingMode.Default;
|
||
}
|
||
|
||
protected override void OnRenderImageMargin(ToolStripRenderEventArgs e)
|
||
{
|
||
// 거터 없음 — 전체 배경과 동일한 색
|
||
using var br = new SolidBrush(BgColor);
|
||
e.Graphics.FillRectangle(br, e.AffectedBounds);
|
||
}
|
||
|
||
// ─── 아이템 배경 ─────────────────────────────────────────────────────
|
||
|
||
protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e)
|
||
{
|
||
var g = e.Graphics;
|
||
var item = e.Item;
|
||
|
||
// 기본 배경 (항상 메뉴 배경색으로 초기화)
|
||
using var bg = new SolidBrush(BgColor);
|
||
g.FillRectangle(bg, 0, 0, item.Width, item.Height);
|
||
|
||
if (!item.Selected || !item.Enabled) return;
|
||
|
||
// 호버: 안쪽에 둥근 모서리 하이라이트
|
||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||
using var hoverBr = new SolidBrush(HoverColor);
|
||
DrawRoundedFill(g, hoverBr, new RectangleF(6, 2, item.Width - 12, item.Height - 4), 8f);
|
||
g.SmoothingMode = SmoothingMode.Default;
|
||
}
|
||
|
||
// ─── 구분선 ──────────────────────────────────────────────────────────
|
||
|
||
protected override void OnRenderSeparator(ToolStripSeparatorRenderEventArgs e)
|
||
{
|
||
int y = e.Item.Height / 2;
|
||
using var pen = new Pen(SepColor);
|
||
e.Graphics.DrawLine(pen, 16, y, e.Item.Width - 16, y);
|
||
}
|
||
|
||
// ─── 텍스트 ──────────────────────────────────────────────────────────
|
||
|
||
protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e)
|
||
{
|
||
e.TextColor = e.Item.Enabled ? TextColor : TextDimColor;
|
||
e.TextFormat = TextFormatFlags.Left | TextFormatFlags.VerticalCenter;
|
||
base.OnRenderItemText(e);
|
||
}
|
||
|
||
// ─── 아이콘 (일반 아이템 + CheckOnClick 모두 처리) ───────────────────
|
||
|
||
protected override void OnRenderItemImage(ToolStripItemImageRenderEventArgs e)
|
||
{
|
||
// 모든 아이템의 글리프를 여기서 그린다
|
||
DrawGlyph(e.Graphics, e.Item, e.ImageRectangle);
|
||
}
|
||
|
||
protected override void OnRenderItemCheck(ToolStripItemImageRenderEventArgs e)
|
||
{
|
||
// CheckOnClick 항목: 기본 체크마크 대신 글리프 아이콘을 그린다
|
||
DrawGlyph(e.Graphics, e.Item, e.ImageRectangle);
|
||
}
|
||
|
||
// ─── 글리프 렌더러 ───────────────────────────────────────────────────
|
||
|
||
private static void DrawGlyph(Graphics g, ToolStripItem item, Rectangle bounds)
|
||
{
|
||
if (item.Tag is not string glyph || string.IsNullOrEmpty(glyph)) return;
|
||
if (bounds.Width <= 0 || bounds.Height <= 0) return;
|
||
|
||
g.TextRenderingHint = TextRenderingHint.AntiAlias;
|
||
|
||
bool isLightbulb = glyph == TrayContextMenu.GlyphAutoRun;
|
||
bool isChecked = item is ToolStripMenuItem { Checked: true };
|
||
|
||
Color glyphColor;
|
||
if (isLightbulb && isChecked)
|
||
{
|
||
// 전구 켜짐 — 앰버 글로우 효과
|
||
DrawLightbulbGlow(g, glyph, bounds);
|
||
glyphColor = BulbOnColor;
|
||
}
|
||
else if (isLightbulb && !isChecked)
|
||
{
|
||
// 전구 꺼짐 — 확실히 구분되는 진한 회색
|
||
glyphColor = Color.FromArgb(120, 120, 140);
|
||
}
|
||
else if (item.Selected && item.Enabled)
|
||
{
|
||
glyphColor = AccentColor;
|
||
}
|
||
else
|
||
{
|
||
glyphColor = IconColor;
|
||
}
|
||
|
||
// Point 단위는 DPI와 무관하게 동일한 물리 크기 → 역산하지 않음
|
||
using var font = new Font("Segoe MDL2 Assets", 12f, GraphicsUnit.Point);
|
||
using var br = new SolidBrush(glyphColor);
|
||
var fmt = StringFormat.GenericTypographic;
|
||
var size = g.MeasureString(glyph, font, 0, fmt);
|
||
// 넓은 아이콘 영역 내 중앙 정렬
|
||
float x = bounds.X + (bounds.Width - size.Width) / 2f + 2f;
|
||
float y = bounds.Y + (bounds.Height - size.Height) / 2f;
|
||
g.DrawString(glyph, font, br, x, y, fmt);
|
||
}
|
||
|
||
private static void DrawLightbulbGlow(Graphics g, string glyph, Rectangle bounds)
|
||
{
|
||
// 중심에서 균일하게 퍼지는 원형 앰버 글로우
|
||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||
float cx = bounds.X + bounds.Width / 2f;
|
||
float cy = bounds.Y + bounds.Height / 2f;
|
||
float radius = Math.Max(bounds.Width, bounds.Height) * 0.85f;
|
||
|
||
var glowRect = new RectangleF(cx - radius, cy - radius, radius * 2, radius * 2);
|
||
using var path = new GraphicsPath();
|
||
path.AddEllipse(glowRect);
|
||
using var brush = new PathGradientBrush(path)
|
||
{
|
||
CenterColor = Color.FromArgb(60, 255, 185, 0),
|
||
SurroundColors = new[] { Color.FromArgb(0, 255, 185, 0) },
|
||
CenterPoint = new PointF(cx, cy)
|
||
};
|
||
g.FillEllipse(brush, glowRect);
|
||
g.SmoothingMode = SmoothingMode.Default;
|
||
}
|
||
|
||
// ─── 유틸 ────────────────────────────────────────────────────────────
|
||
|
||
private static GraphicsPath BuildRoundedPath(RectangleF rect, float radius)
|
||
{
|
||
float d = radius * 2f;
|
||
var path = new GraphicsPath();
|
||
path.AddArc(rect.X, rect.Y, d, d, 180, 90);
|
||
path.AddArc(rect.Right - d, rect.Y, d, d, 270, 90);
|
||
path.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90);
|
||
path.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90);
|
||
path.CloseFigure();
|
||
return path;
|
||
}
|
||
|
||
private static void DrawRoundedFill(Graphics g, Brush brush, RectangleF rect, float radius)
|
||
{
|
||
using var path = BuildRoundedPath(rect, radius);
|
||
g.FillPath(brush, path);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
internal sealed class ModernColorTable : ProfessionalColorTable
|
||
{
|
||
private static Color TC(string key, Color fb)
|
||
{
|
||
try
|
||
{
|
||
if (System.Windows.Application.Current?.Resources[key]
|
||
is System.Windows.Media.SolidColorBrush b)
|
||
return Color.FromArgb(b.Color.A, b.Color.R, b.Color.G, b.Color.B);
|
||
}
|
||
catch { }
|
||
return fb;
|
||
}
|
||
|
||
private static Color Bg => TC("LauncherBackground", Color.FromArgb(250, 250, 252));
|
||
private static Color Hover => TC("ItemHoverBackground", Color.FromArgb(234, 234, 247));
|
||
private static Color Border => TC("BorderColor", Color.FromArgb(210, 210, 232));
|
||
private static Color Sep => TC("SeparatorColor", Color.FromArgb(228, 228, 242));
|
||
|
||
public override Color MenuBorder => Border;
|
||
public override Color ToolStripDropDownBackground => Bg;
|
||
public override Color ImageMarginGradientBegin => Bg;
|
||
public override Color ImageMarginGradientMiddle => Bg;
|
||
public override Color ImageMarginGradientEnd => Bg;
|
||
public override Color MenuItemSelected => Hover;
|
||
public override Color MenuItemSelectedGradientBegin => Hover;
|
||
public override Color MenuItemSelectedGradientEnd => Hover;
|
||
public override Color MenuItemBorder => Color.Transparent;
|
||
public override Color MenuItemPressedGradientBegin => Hover;
|
||
public override Color MenuItemPressedGradientEnd => Hover;
|
||
public override Color SeparatorLight => Sep;
|
||
public override Color SeparatorDark => Sep;
|
||
public override Color CheckBackground => Color.Transparent;
|
||
public override Color CheckSelectedBackground => Color.Transparent;
|
||
public override Color CheckPressedBackground => Color.Transparent;
|
||
}
|
||
|