Some checks failed
Release Gate / gate (push) Has been cancelled
변경 목적: 트레이 아이콘 우클릭 메뉴 최상단의 앱 이름/버전 표시가 본문 항목보다 덜 튀면서도 더 안정적으로 보이도록 시각 대비를 조정했습니다. 핵심 수정사항: TrayMenuWindow의 AddHeader 텍스트에 헤더 전용 진한 회색 브러시를 적용하고, README와 DEVELOPMENT 문서에 작업 이력과 검증 결과를 반영했습니다. 검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
289 lines
9.9 KiB
C#
289 lines
9.9 KiB
C#
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;
|
|
private static readonly Brush HeaderTextBrush = new SolidColorBrush(Color.FromRgb(96, 96, 96));
|
|
|
|
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 AddHeader(string text)
|
|
{
|
|
var label = new TextBlock
|
|
{
|
|
Text = text,
|
|
FontSize = 12,
|
|
FontWeight = FontWeights.SemiBold,
|
|
Margin = new Thickness(12, 2, 12, 6),
|
|
};
|
|
label.Foreground = HeaderTextBrush;
|
|
MenuPanel.Children.Add(label);
|
|
AddSeparator();
|
|
return this;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|