Files
AX-Copilot-Codex/src/AxCopilot/Views/TrayContextMenu.cs

374 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}