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;
}
}