Initial commit to new repository
This commit is contained in:
271
src/AxCopilot/Views/TrayMenuWindow.xaml.cs
Normal file
271
src/AxCopilot/Views/TrayMenuWindow.xaml.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// WPF 기반 커스텀 트레이 컨텍스트 메뉴.
|
||||
/// WinForms ContextMenuStrip의 DPI 불일치 문제를 근본 해결합니다.
|
||||
/// </summary>
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>일반 메뉴 항목을 추가합니다.</summary>
|
||||
public TrayMenuWindow AddItem(string glyph, string text, Action onClick)
|
||||
{
|
||||
var item = CreateItemBorder(glyph, text);
|
||||
item.MouseLeftButtonUp += (_, _) => { Hide(); onClick(); };
|
||||
MenuPanel.Children.Add(item);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>일반 메뉴 항목을 추가하고 Border 참조를 반환합니다 (동적 가시성 제어용).</summary>
|
||||
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));
|
||||
|
||||
/// <summary>토글(체크) 메뉴 항목을 추가합니다.</summary>
|
||||
public TrayMenuWindow AddToggleItem(string glyph, string text, bool initialChecked,
|
||||
Action<bool> onToggle, out Func<bool> getChecked, out Action<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>구분선을 추가합니다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── 팝업 표시 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>트레이 아이콘 근처에 메뉴를 표시합니다.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>메뉴를 표시하기 직전에 동적 항목을 갱신할 수 있는 이벤트.</summary>
|
||||
public event Action? Opening;
|
||||
|
||||
/// <summary>Opening 이벤트를 트리거하고 메뉴를 표시합니다.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user