using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; namespace AxCopilot.Views; /// /// WPF 기반 커스텀 트레이 컨텍스트 메뉴. /// WinForms ContextMenuStrip의 DPI 불일치 문제를 근본 해결합니다. /// public partial class TrayMenuWindow : Window { private System.Windows.Threading.DispatcherTimer? _autoCloseTimer; public TrayMenuWindow() { InitializeComponent(); // 마우스가 메뉴 밖으로 나가면 일정 시간 후 자동 닫힘 MouseLeave += (_, _) => { _autoCloseTimer?.Stop(); _autoCloseTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) }; _autoCloseTimer.Tick += (_, _) => { _autoCloseTimer.Stop(); Hide(); }; _autoCloseTimer.Start(); }; // 마우스가 다시 메뉴 위로 오면 타이머 취소 MouseEnter += (_, _) => { _autoCloseTimer?.Stop(); }; } // ─── 아이템 빌더 API ───────────────────────────────────────────────── /// 일반 메뉴 항목을 추가합니다. public TrayMenuWindow AddItem(string glyph, string text, Action onClick) { var item = CreateItemBorder(glyph, text); item.MouseLeftButtonUp += (_, _) => { Hide(); onClick(); }; MenuPanel.Children.Add(item); return this; } /// 일반 메뉴 항목을 추가하고 Border 참조를 반환합니다 (동적 가시성 제어용). public TrayMenuWindow AddItem(string glyph, string text, Action onClick, out Border itemRef) { var item = CreateItemBorder(glyph, text); item.MouseLeftButtonUp += (_, _) => { Hide(); onClick(); }; MenuPanel.Children.Add(item); itemRef = item; return this; } // 전구 아이콘 활성 색상 — 앰버(노란) 글로우 private static readonly Brush BulbOnBrush = new SolidColorBrush(Color.FromRgb(255, 185, 0)); private static readonly Brush BulbOffBrush = new SolidColorBrush(Color.FromRgb(120, 120, 140)); /// 토글(체크) 메뉴 항목을 추가합니다. public TrayMenuWindow AddToggleItem(string glyph, string text, bool initialChecked, Action onToggle, out Func getChecked, out Action setText) { bool isChecked = initialChecked; var glyphBlock = new TextBlock { Text = glyph, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14, Foreground = isChecked ? BulbOnBrush : BulbOffBrush, VerticalAlignment = VerticalAlignment.Center, Width = 20, TextAlignment = TextAlignment.Center, }; // 켜진 상태에서 글로우 이펙트 적용 if (isChecked) glyphBlock.Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Color.FromRgb(255, 185, 0), BlurRadius = 12, ShadowDepth = 0, Opacity = 0.7 }; var label = new TextBlock { Text = text, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, }; label.SetResourceReference(TextBlock.ForegroundProperty, "PrimaryText"); var panel = new StackPanel { Orientation = Orientation.Horizontal, Children = { glyphBlock, label }, }; var border = new Border { Child = panel, CornerRadius = new CornerRadius(6), Padding = new Thickness(12, 8, 16, 8), Cursor = Cursors.Hand, Background = Brushes.Transparent, }; border.MouseEnter += (_, _) => border.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; border.MouseLeave += (_, _) => border.Background = Brushes.Transparent; border.MouseLeftButtonUp += (_, _) => { isChecked = !isChecked; glyphBlock.Foreground = isChecked ? BulbOnBrush : BulbOffBrush; glyphBlock.Effect = isChecked ? new System.Windows.Media.Effects.DropShadowEffect { Color = Color.FromRgb(255, 185, 0), BlurRadius = 12, ShadowDepth = 0, Opacity = 0.7 } : null; onToggle(isChecked); }; getChecked = () => isChecked; setText = t => label.Text = t; MenuPanel.Children.Add(border); return this; } /// 구분선을 추가합니다. public TrayMenuWindow AddSeparator() { var sep = new Border { Height = 1, Margin = new Thickness(16, 5, 16, 5), }; sep.SetResourceReference(Border.BackgroundProperty, "SeparatorColor"); MenuPanel.Children.Add(sep); return this; } // ─── 팝업 표시 ──────────────────────────────────────────────────────── /// 트레이 아이콘 근처에 메뉴를 표시합니다. public void ShowAtTray() { var workArea = SystemParameters.WorkArea; // 먼저 표시하여 ActualWidth/ActualHeight 확정 Opacity = 0; Show(); UpdateLayout(); double menuW = ActualWidth; double menuH = ActualHeight; // 마우스 커서 위치 (물리 픽셀 → WPF 논리 좌표) var cursorPos = GetCursorPosition(); double dpiScale = VisualTreeHelper.GetDpi(this).PixelsPerDip; double cx = cursorPos.X / dpiScale; double cy = cursorPos.Y / dpiScale; // 메뉴 우하단이 커서에서 살짝 떨어지도록 배치 double left = cx - menuW - 8; double top = cy - menuH - 8; // 화면 밖 보정 if (left < workArea.Left) left = workArea.Left + 4; if (top < workArea.Top) top = workArea.Top + 4; if (left + menuW > workArea.Right) left = workArea.Right - menuW - 4; if (top + menuH > workArea.Bottom) top = workArea.Bottom - menuH - 4; Left = left; Top = top; // 활성화하여 Deactivated 이벤트가 정상 작동하도록 보장 Activate(); // 페이드 인 애니메이션 var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(120)) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; BeginAnimation(OpacityProperty, fadeIn); } /// 메뉴를 표시하기 직전에 동적 항목을 갱신할 수 있는 이벤트. public event Action? Opening; /// Opening 이벤트를 트리거하고 메뉴를 표시합니다. public void ShowWithUpdate() { Opening?.Invoke(); ShowAtTray(); } // ─── 내부 헬퍼 ──────────────────────────────────────────────────────── private Border CreateItemBorder(string glyph, string text) { var glyphBlock = new TextBlock { Text = glyph, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Width = 20, TextAlignment = TextAlignment.Center, }; glyphBlock.SetResourceReference(TextBlock.ForegroundProperty, "SecondaryText"); var label = new TextBlock { Text = text, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, }; label.SetResourceReference(TextBlock.ForegroundProperty, "PrimaryText"); var panel = new StackPanel { Orientation = Orientation.Horizontal, Children = { glyphBlock, label }, }; var border = new Border { Child = panel, CornerRadius = new CornerRadius(6), Padding = new Thickness(12, 8, 16, 8), Cursor = Cursors.Hand, Background = Brushes.Transparent, }; border.MouseEnter += (_, _) => { border.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; glyphBlock.Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; }; border.MouseLeave += (_, _) => { border.Background = Brushes.Transparent; glyphBlock.SetResourceReference(TextBlock.ForegroundProperty, "SecondaryText"); }; return border; } private void Window_Deactivated(object sender, EventArgs e) { Hide(); } [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetCursorPos(out POINT lpPoint); [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; } private static POINT GetCursorPosition() { GetCursorPos(out var pt); return pt; } }