using System.Runtime.InteropServices; using System.Windows; using System.Windows.Input; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Animation; using Microsoft.Win32; using AxCopilot.Models; using AxCopilot.ViewModels; using FormsCursor = System.Windows.Forms.Cursor; using FormsScreen = System.Windows.Forms.Screen; namespace AxCopilot.Views; public partial class LauncherWindow : Window { private static App? CurrentApp => System.Windows.Application.Current as App; [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); [DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); private readonly LauncherViewModel _vm; private System.Windows.Threading.DispatcherTimer? _indexStatusTimer; private System.Windows.Threading.DispatcherTimer? _toastTimer; /// Ctrl+, 단축키로 설정 창을 여는 콜백 (App.xaml.cs에서 주입) public Action? OpenSettingsAction { get; set; } public LauncherWindow(LauncherViewModel vm) { _vm = vm; // 테마를 InitializeComponent 전에 적용 → 첫 렌더링부터 올바른 테마 표시 (깜빡임 방지) ApplyTheme(); InitializeComponent(); DataContext = vm; vm.CloseRequested += (_, _) => Hide(); vm.NotificationRequested += (_, msg) => ShowNotification(msg); // InputText가 코드에서 변경될 때 커서를 끝으로 이동 // (F1→"help", Ctrl+B→"fav", Ctrl+D→"cd Downloads" 등 자동 입력 후 위화감 해소) vm.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(LauncherViewModel.InputText)) { // DispatcherPriority.Input: 바인딩 업데이트가 완료된 직후 실행 Dispatcher.InvokeAsync( () => { if (InputBox != null) InputBox.CaretIndex = InputBox.Text.Length; }, System.Windows.Threading.DispatcherPriority.Input); } }; // 인덱싱 완료 시 상태 표시 var app = (App)System.Windows.Application.Current; if (app.IndexService != null) { app.IndexService.IndexRebuilt += (_, _) => { Dispatcher.BeginInvoke(() => { var svc = app.IndexService; ShowIndexStatus( $"✓ {svc.LastIndexCount:N0}개 항목 색인됨 ({svc.LastIndexDuration.TotalSeconds:F1}초)", TimeSpan.FromSeconds(5)); }); }; } } private void Window_Loaded(object sender, RoutedEventArgs e) { ApplyInitialPlacement(); ApplyTheme(); Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, () => { Activate(); ForceForeground(); InputBox.Focus(); Keyboard.Focus(InputBox); }); } private static readonly Random _iconRng = new(); private System.Windows.Media.Animation.Storyboard? _iconStoryboard; private System.Windows.Threading.DispatcherTimer? _rainbowTimer; /// 외부에서 입력 텍스트를 설정합니다 (AI 명령 전달용). public void SetInputText(string text) { if (InputBox == null) return; InputBox.Text = text; InputBox.CaretIndex = text.Length; // 자동 실행 _vm.InputText = text; } /// /// 포그라운드 창 전환 제한을 우회하여 확실히 포커스를 가져옵니다. /// Windows는 현재 포그라운드 스레드가 아닌 스레드에서 SetForegroundWindow를 차단하므로, /// AttachThreadInput으로 일시적으로 스레드를 연결합니다. /// private void ForceForeground() { try { var hwnd = new WindowInteropHelper(this).Handle; if (hwnd == IntPtr.Zero) return; var foreHwnd = GetForegroundWindow(); if (foreHwnd == hwnd) return; // 이미 포그라운드 uint foreThread = GetWindowThreadProcessId(foreHwnd, out _); uint thisThread = GetCurrentThreadId(); if (foreThread != thisThread) { AttachThreadInput(thisThread, foreThread, true); SetForegroundWindow(hwnd); AttachThreadInput(thisThread, foreThread, false); } else { SetForegroundWindow(hwnd); } } catch { /* 포커스 강제 실패 시 기본 WPF Activate에 의존 */ } } public new void Show() { _vm.RefreshPlaceholder(); _vm.OnShown(); _vm.InputText = ""; base.Show(); ApplyInitialPlacement(); AnimateIn(); // 포그라운드 강제 + 포커스를 3단계로 보장 // 1단계: 즉시 활성화 Activate(); ForceForeground(); // 2단계: 레이아웃 완료 후 포커스 Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, () => { Activate(); InputBox.Focus(); Keyboard.Focus(InputBox); InputBox.SelectAll(); }); // 3단계: 렌더링 완료 후 최종 포커스 보장 (애니메이션 끝난 뒤) Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, () => { if (!InputBox.IsKeyboardFocused) { ForceForeground(); InputBox.Focus(); Keyboard.Focus(InputBox); } }); // 런처 테두리 표시 설정 if (_vm.ShowLauncherBorder) { MainBorder.BorderThickness = new Thickness(1); MainBorder.BorderBrush = (System.Windows.Media.Brush)FindResource("BorderColor"); } else { MainBorder.BorderThickness = new Thickness(0); MainBorder.BorderBrush = System.Windows.Media.Brushes.Transparent; } if (_vm.EnableIconAnimation) ApplyRandomIconAnimation(); else ResetIconAnimation(); if (_vm.EnableRainbowGlow) StartRainbowGlow(); else StopRainbowGlow(); UpdateSelectionGlow(); } /// 아이콘 애니메이션을 중지하고 모든 픽셀을 완전 점등 상태로 복원합니다. private void ResetIconAnimation() { _iconStoryboard?.Stop(); _iconStoryboard = null; var pixels = new[] { PixelBlue, PixelGreen1, PixelRed, PixelGreen2 }; foreach (var p in pixels) { if (p == null) continue; p.BeginAnimation(UIElement.OpacityProperty, null); p.Opacity = 1; } if (IconRotate != null) { IconRotate.BeginAnimation(RotateTransform.AngleProperty, null); IconRotate.Angle = 45; } if (IconScale != null) { IconScale.BeginAnimation(ScaleTransform.ScaleXProperty, null); IconScale.BeginAnimation(ScaleTransform.ScaleYProperty, null); IconScale.ScaleX = 1; IconScale.ScaleY = 1; } } /// 10가지 다이아몬드 픽셀 애니메이션 효과 중 랜덤 1개를 적용합니다. private void ApplyRandomIconAnimation() { _iconStoryboard?.Stop(); var pixels = new[] { PixelBlue, PixelGreen1, PixelRed, PixelGreen2 }; if (pixels.Any(p => p == null) || IconRotate == null || IconScale == null) return; // 모든 상태 초기화 foreach (var p in pixels) { p.BeginAnimation(UIElement.OpacityProperty, null); p.Opacity = 1; } IconRotate.BeginAnimation(RotateTransform.AngleProperty, null); IconScale.BeginAnimation(ScaleTransform.ScaleXProperty, null); IconScale.BeginAnimation(ScaleTransform.ScaleYProperty, null); IconRotate.Angle = 45; IconScale.ScaleX = 1; IconScale.ScaleY = 1; var sb = new Storyboard { RepeatBehavior = new RepeatBehavior(3) }; // 0~9: 기존 효과, 9: 정적(애니 없음), 10~14: 추가 효과, 15~19: 역동적 추가 효과 int effect = _iconRng.Next(20); switch (effect) { case 0: // 순차 점멸 — 파랑→초록1→빨강→초록2 순서로 깜빡임 for (int i = 0; i < 4; i++) AddOpacityPulse(sb, pixels[i], i, 4.0); break; case 1: // 전체 호흡 — 4개 동시에 밝아졌다 어두워짐 foreach (var p in pixels) { var a = new DoubleAnimation(1, 0.35, TimeSpan.FromSeconds(1.5)) { AutoReverse = true }; Storyboard.SetTarget(a, p); Storyboard.SetTargetProperty(a, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(a); } break; case 2: // 대각선 교차 — 좌상+우하 vs 우상+좌하 번갈아 깜빡임 AddGroupFlash(sb, new[] { pixels[0], pixels[3] }, 0, 3.2); AddGroupFlash(sb, new[] { pixels[1], pixels[2] }, 1.6, 3.2); break; case 3: // 시계방향 순회 점등 — 한 개씩 순서대로 밝아짐 { var order = new[] { pixels[0], pixels[1], pixels[3], pixels[2] }; for (int i = 0; i < 4; i++) { var a = MakeKeyFrameAnim(new (double val, double sec)[] { (0.15, 0), (0.15, i*0.5), (1, i*0.5+0.25), (1, i*0.5+0.7), (0.15, i*0.5+0.95), (0.15, 2.5) }); Storyboard.SetTarget(a, order[i]); Storyboard.SetTargetProperty(a, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(a); } break; } case 4: // 💫 천천히 360도 회전 (PPT 회전판 효과) — 다이아몬드 전체가 돌아감 { var rot = new DoubleAnimation(45, 405, TimeSpan.FromSeconds(4)) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut } }; Storyboard.SetTarget(rot, DiamondIcon); Storyboard.SetTargetProperty(rot, new PropertyPath("RenderTransform.Children[0].Angle")); sb.Children.Add(rot); break; } case 5: // 💫 펄스 확대/축소 (PPT 강조 효과) — 전체가 커졌다 작아짐 { var sx = new DoubleAnimation(1, 1.25, TimeSpan.FromSeconds(0.6)) { AutoReverse = true, EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseInOut } }; var sy = new DoubleAnimation(1, 1.25, TimeSpan.FromSeconds(0.6)) { AutoReverse = true, EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseInOut } }; Storyboard.SetTarget(sx, DiamondIcon); Storyboard.SetTarget(sy, DiamondIcon); Storyboard.SetTargetProperty(sx, new PropertyPath("RenderTransform.Children[1].ScaleX")); Storyboard.SetTargetProperty(sy, new PropertyPath("RenderTransform.Children[1].ScaleY")); sb.Children.Add(sx); sb.Children.Add(sy); break; } case 6: // 💫 바운스 등장 — 작아졌다가 탄력적으로 커짐 (PPT 바운스) { sb.RepeatBehavior = RepeatBehavior.Forever; var bx = new DoubleAnimationUsingKeyFrames(); bx.KeyFrames.Add(new LinearDoubleKeyFrame(0.7, KT(0))); bx.KeyFrames.Add(new EasingDoubleKeyFrame(1.15, KT(0.35), new BounceEase { Bounces = 2, Bounciness = 3 })); bx.KeyFrames.Add(new LinearDoubleKeyFrame(1.0, KT(0.6))); bx.KeyFrames.Add(new LinearDoubleKeyFrame(1.0, KT(3.0))); var by = bx.Clone(); Storyboard.SetTarget(bx, DiamondIcon); Storyboard.SetTarget(by, DiamondIcon); Storyboard.SetTargetProperty(bx, new PropertyPath("RenderTransform.Children[1].ScaleX")); Storyboard.SetTargetProperty(by, new PropertyPath("RenderTransform.Children[1].ScaleY")); sb.Children.Add(bx); sb.Children.Add(by); break; } case 7: // 💫 진동 흔들림 (PPT 티터) — 좌우로 살짝 흔들림 { var shake = new DoubleAnimationUsingKeyFrames(); shake.KeyFrames.Add(new LinearDoubleKeyFrame(45, KT(0))); shake.KeyFrames.Add(new LinearDoubleKeyFrame(50, KT(0.08))); shake.KeyFrames.Add(new LinearDoubleKeyFrame(40, KT(0.16))); shake.KeyFrames.Add(new LinearDoubleKeyFrame(48, KT(0.24))); shake.KeyFrames.Add(new LinearDoubleKeyFrame(42, KT(0.32))); shake.KeyFrames.Add(new LinearDoubleKeyFrame(45, KT(0.4))); shake.KeyFrames.Add(new LinearDoubleKeyFrame(45, KT(3.5))); Storyboard.SetTarget(shake, DiamondIcon); Storyboard.SetTargetProperty(shake, new PropertyPath("RenderTransform.Children[0].Angle")); sb.Children.Add(shake); break; } case 8: // 💫 파도 — 좌측에서 우측으로 순차적으로 확대됐다 복귀 { for (int i = 0; i < 4; i++) { var wave = new DoubleAnimationUsingKeyFrames(); double d = i * 0.3; wave.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(0))); wave.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(d))); wave.KeyFrames.Add(new EasingDoubleKeyFrame(0.1, KT(d + 0.25), new QuadraticEase { EasingMode = EasingMode.EaseOut })); wave.KeyFrames.Add(new EasingDoubleKeyFrame(1, KT(d + 0.55), new QuadraticEase { EasingMode = EasingMode.EaseIn })); wave.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(3))); Storyboard.SetTarget(wave, pixels[i]); Storyboard.SetTargetProperty(wave, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(wave); } break; } case 9: // 정적 — 완전 점등 상태 (쉬는 턴, 애니메이션 없음) foreach (var p in pixels) p.Opacity = 1; return; case 10: // 💫 역방향 회전 — 반시계 방향으로 천천히 회전 { var rot = new DoubleAnimation(45, -315, TimeSpan.FromSeconds(4.5)) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut } }; Storyboard.SetTarget(rot, DiamondIcon); Storyboard.SetTargetProperty(rot, new PropertyPath("RenderTransform.Children[0].Angle")); sb.Children.Add(rot); break; } case 11: // 💫 하트비트 — 짧은 이중 펄스가 규칙적으로 반복 { var hb = new DoubleAnimationUsingKeyFrames(); hb.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(0))); hb.KeyFrames.Add(new EasingDoubleKeyFrame(1.3, KT(0.1), new QuadraticEase { EasingMode = EasingMode.EaseOut })); hb.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, KT(0.22), new QuadraticEase { EasingMode = EasingMode.EaseIn })); hb.KeyFrames.Add(new EasingDoubleKeyFrame(1.2, KT(0.32), new QuadraticEase { EasingMode = EasingMode.EaseOut })); hb.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, KT(0.44), new QuadraticEase { EasingMode = EasingMode.EaseIn })); hb.KeyFrames.Add(new LinearDoubleKeyFrame(1.0, KT(2.8))); var hby = hb.Clone(); Storyboard.SetTarget(hb, DiamondIcon); Storyboard.SetTarget(hby, DiamondIcon); Storyboard.SetTargetProperty(hb, new PropertyPath("RenderTransform.Children[1].ScaleX")); Storyboard.SetTargetProperty(hby, new PropertyPath("RenderTransform.Children[1].ScaleY")); sb.Children.Add(hb); sb.Children.Add(hby); break; } case 12: // 💫 별빛 반짝임 — 픽셀이 제각각 랜덤하게 반짝 { var offsets = new[] { 0.0, 0.5, 1.1, 1.6 }; for (int i = 0; i < 4; i++) { double d = offsets[i]; var twinkle = new DoubleAnimationUsingKeyFrames(); twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(0))); twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(d))); twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(0.05, KT(d + 0.15))); twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(d + 0.35))); twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(0.5, KT(d + 0.5))); twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(d + 0.65))); twinkle.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(3.5))); Storyboard.SetTarget(twinkle, pixels[i]); Storyboard.SetTargetProperty(twinkle, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(twinkle); } break; } case 13: // 💫 나선 등장 — 작게 시작해 회전하며 확대 { var spiralRot = new DoubleAnimation(45 - 180, 45, TimeSpan.FromSeconds(0.9)) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; var spiralSx = new DoubleAnimation(0.4, 1, TimeSpan.FromSeconds(0.9)) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; var spiralSy = new DoubleAnimation(0.4, 1, TimeSpan.FromSeconds(0.9)) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; // 등장 후 2.5초 대기, 다시 반복 var hold = new DoubleAnimation(1, 1, TimeSpan.FromSeconds(2.5)) { BeginTime = TimeSpan.FromSeconds(0.9) }; Storyboard.SetTarget(spiralRot, DiamondIcon); Storyboard.SetTarget(spiralSx, DiamondIcon); Storyboard.SetTarget(spiralSy, DiamondIcon); Storyboard.SetTarget(hold, DiamondIcon); Storyboard.SetTargetProperty(spiralRot, new PropertyPath("RenderTransform.Children[0].Angle")); Storyboard.SetTargetProperty(spiralSx, new PropertyPath("RenderTransform.Children[1].ScaleX")); Storyboard.SetTargetProperty(spiralSy, new PropertyPath("RenderTransform.Children[1].ScaleY")); Storyboard.SetTargetProperty(hold, new PropertyPath("RenderTransform.Children[1].ScaleX")); sb.Children.Add(spiralRot); sb.Children.Add(spiralSx); sb.Children.Add(spiralSy); break; } case 14: // 💫 색상별 소멸·복원 — 픽셀이 하나씩 사라졌다 다시 나타남 { for (int i = 0; i < 4; i++) { double d = i * 0.6; var vanish = new DoubleAnimationUsingKeyFrames(); vanish.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(0))); vanish.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(d))); vanish.KeyFrames.Add(new EasingDoubleKeyFrame(0, KT(d + 0.3), new QuadraticEase { EasingMode = EasingMode.EaseIn })); vanish.KeyFrames.Add(new EasingDoubleKeyFrame(1, KT(d + 0.6), new QuadraticEase { EasingMode = EasingMode.EaseOut })); vanish.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(3.5))); Storyboard.SetTarget(vanish, pixels[i]); Storyboard.SetTargetProperty(vanish, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(vanish); } break; } case 15: // 💫 스핀+확대 콤보 — 회전하면서 커졌다 작아짐 { var rot = new DoubleAnimation(45, 405, TimeSpan.FromSeconds(2)) { EasingFunction = new BackEase { EasingMode = EasingMode.EaseInOut, Amplitude = 0.3 } }; var sx = new DoubleAnimationUsingKeyFrames(); sx.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(0))); sx.KeyFrames.Add(new EasingDoubleKeyFrame(1.4, KT(1), new QuadraticEase { EasingMode = EasingMode.EaseOut })); sx.KeyFrames.Add(new EasingDoubleKeyFrame(1, KT(2), new QuadraticEase { EasingMode = EasingMode.EaseIn })); sx.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(3.5))); var sy = sx.Clone(); Storyboard.SetTarget(rot, DiamondIcon); Storyboard.SetTarget(sx, DiamondIcon); Storyboard.SetTarget(sy, DiamondIcon); Storyboard.SetTargetProperty(rot, new PropertyPath("RenderTransform.Children[0].Angle")); Storyboard.SetTargetProperty(sx, new PropertyPath("RenderTransform.Children[1].ScaleX")); Storyboard.SetTargetProperty(sy, new PropertyPath("RenderTransform.Children[1].ScaleY")); sb.Children.Add(rot); sb.Children.Add(sx); sb.Children.Add(sy); break; } case 16: // 💫 탄성 점프 — 위에서 떨어지며 바운스 (각 픽셀 시차) { for (int i = 0; i < 4; i++) { double d = i * 0.15; var bounce = new DoubleAnimationUsingKeyFrames(); bounce.KeyFrames.Add(new LinearDoubleKeyFrame(0, KT(0))); bounce.KeyFrames.Add(new LinearDoubleKeyFrame(0, KT(d))); bounce.KeyFrames.Add(new EasingDoubleKeyFrame(1, KT(d + 0.5), new BounceEase { Bounces = 3, Bounciness = 2.5 })); bounce.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(3))); Storyboard.SetTarget(bounce, pixels[i]); Storyboard.SetTargetProperty(bounce, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(bounce); } break; } case 17: // 💫 진자 운동 — 좌우로 부드럽게 흔들리며 감쇠 { var pendulum = new DoubleAnimationUsingKeyFrames(); pendulum.KeyFrames.Add(new LinearDoubleKeyFrame(45, KT(0))); pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(60, KT(0.3), new SineEase { EasingMode = EasingMode.EaseOut })); pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(30, KT(0.9), new SineEase { EasingMode = EasingMode.EaseInOut })); pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(55, KT(1.5), new SineEase { EasingMode = EasingMode.EaseInOut })); pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(38, KT(2.1), new SineEase { EasingMode = EasingMode.EaseInOut })); pendulum.KeyFrames.Add(new EasingDoubleKeyFrame(45, KT(2.5), new SineEase { EasingMode = EasingMode.EaseIn })); pendulum.KeyFrames.Add(new LinearDoubleKeyFrame(45, KT(4))); Storyboard.SetTarget(pendulum, DiamondIcon); Storyboard.SetTargetProperty(pendulum, new PropertyPath("RenderTransform.Children[0].Angle")); sb.Children.Add(pendulum); break; } case 18: // 💫 폭죽 — 4개 픽셀이 동시에 빠르게 깜빡이다 정지 { for (int i = 0; i < 4; i++) { var rapid = new DoubleAnimationUsingKeyFrames(); double offset = i * 0.08; rapid.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(0))); for (int j = 0; j < 6; j++) { double t = offset + j * 0.12; rapid.KeyFrames.Add(new LinearDoubleKeyFrame(j % 2 == 0 ? 0.1 : 1, KT(t))); } rapid.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(1.2))); rapid.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(3.5))); Storyboard.SetTarget(rapid, pixels[i]); Storyboard.SetTargetProperty(rapid, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(rapid); } // 동시에 빠르게 확대→축소 var pop = new DoubleAnimationUsingKeyFrames(); pop.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(0))); pop.KeyFrames.Add(new EasingDoubleKeyFrame(1.5, KT(0.15), new QuadraticEase { EasingMode = EasingMode.EaseOut })); pop.KeyFrames.Add(new EasingDoubleKeyFrame(0.8, KT(0.4), new ElasticEase { Oscillations = 2, Springiness = 5 })); pop.KeyFrames.Add(new EasingDoubleKeyFrame(1, KT(0.7), new QuadraticEase { EasingMode = EasingMode.EaseOut })); pop.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(3.5))); var popY = pop.Clone(); Storyboard.SetTarget(pop, DiamondIcon); Storyboard.SetTarget(popY, DiamondIcon); Storyboard.SetTargetProperty(pop, new PropertyPath("RenderTransform.Children[1].ScaleX")); Storyboard.SetTargetProperty(popY, new PropertyPath("RenderTransform.Children[1].ScaleY")); sb.Children.Add(pop); sb.Children.Add(popY); break; } case 19: // 💫 DNA 이중나선 — 대각선 쌍이 교대로 밝아지며 회전 { // 대각선 쌍 교차 깜빡 + 느린 회전 AddGroupFlash(sb, new[] { pixels[0], pixels[3] }, 0, 4); AddGroupFlash(sb, new[] { pixels[1], pixels[2] }, 0.8, 4); var dnaRot = new DoubleAnimation(45, 225, TimeSpan.FromSeconds(4)) { EasingFunction = new SineEase { EasingMode = EasingMode.EaseInOut } }; Storyboard.SetTarget(dnaRot, DiamondIcon); Storyboard.SetTargetProperty(dnaRot, new PropertyPath("RenderTransform.Children[0].Angle")); sb.Children.Add(dnaRot); break; } } _iconStoryboard = sb; sb.Completed += (_, _) => { if (_vm.EnableIconAnimation && IsVisible) ApplyRandomIconAnimation(); }; sb.Begin(this, true); } /// 아이콘 클릭 시 다른 랜덤 애니메이션으로 전환. private void DiamondIcon_Click(object sender, MouseButtonEventArgs e) { if (!_vm.EnableIconAnimation) return; ApplyRandomIconAnimation(); } // ─── 무지개 글로우 상시 애니메이션 ──────────────────────────────────── /// 선택 아이템 상시 무지개 글로우 효과를 적용하거나 제거합니다. private void UpdateSelectionGlow() { if (_vm.EnableSelectionGlow) { var gs = new System.Windows.Media.GradientStopCollection { new System.Windows.Media.GradientStop(System.Windows.Media.Color.FromRgb(0xFF, 0x6B, 0x6B), 0.00), new System.Windows.Media.GradientStop(System.Windows.Media.Color.FromRgb(0xFE, 0xCA, 0x57), 0.17), new System.Windows.Media.GradientStop(System.Windows.Media.Color.FromRgb(0x48, 0xDB, 0xFB), 0.33), new System.Windows.Media.GradientStop(System.Windows.Media.Color.FromRgb(0xFF, 0x9F, 0xF3), 0.50), new System.Windows.Media.GradientStop(System.Windows.Media.Color.FromRgb(0x54, 0xA0, 0xFF), 0.67), new System.Windows.Media.GradientStop(System.Windows.Media.Color.FromRgb(0x5F, 0x27, 0xCD), 0.83), new System.Windows.Media.GradientStop(System.Windows.Media.Color.FromRgb(0xFF, 0x6B, 0x6B), 1.00), }; Resources["SelectionGlowBrush"] = new System.Windows.Media.LinearGradientBrush( gs, new System.Windows.Point(0, 0), new System.Windows.Point(1, 1)); Resources["SelectionGlowVisibility"] = Visibility.Visible; } else { Resources["SelectionGlowBrush"] = System.Windows.Media.Brushes.Transparent; Resources["SelectionGlowVisibility"] = Visibility.Collapsed; } } /// 무지개 글로우를 정지하고 숨깁니다. private void StopRainbowGlow() { _rainbowTimer?.Stop(); _rainbowTimer = null; if (RainbowGlowBorder != null) RainbowGlowBorder.Opacity = 0; } /// 런처 테두리 무지개 그라데이션 회전을 시작합니다. private void StartRainbowGlow() { _rainbowTimer?.Stop(); if (LauncherRainbowBrush == null || RainbowGlowBorder == null) return; _rainbowTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) }; var startTime = DateTime.UtcNow; _rainbowTimer.Tick += (_, _) => { if (!IsVisible) { _rainbowTimer?.Stop(); return; } var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; var shift = (elapsed / 2000.0) % 1.0; // 2초에 1바퀴 (느리게) var angle = shift * Math.PI * 2; LauncherRainbowBrush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle)); LauncherRainbowBrush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle)); }; _rainbowTimer.Start(); } // ─── 애니메이션 헬퍼 ────────────────────────────────────────────────── private static KeyTime KT(double sec) => KeyTime.FromTimeSpan(TimeSpan.FromSeconds(sec)); private static void AddOpacityPulse(Storyboard sb, UIElement target, int index, double totalSec) { var a = new DoubleAnimationUsingKeyFrames(); a.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(index))); a.KeyFrames.Add(new LinearDoubleKeyFrame(0.25, KT(index + 0.5))); a.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(index + 1))); a.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(totalSec))); Storyboard.SetTarget(a, target); Storyboard.SetTargetProperty(a, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(a); } private static void AddGroupFlash(Storyboard sb, UIElement[] group, double startSec, double totalSec) { foreach (var p in group) { var a = new DoubleAnimationUsingKeyFrames(); a.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(0))); a.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(startSec))); a.KeyFrames.Add(new LinearDoubleKeyFrame(0.2, KT(startSec + 0.6))); a.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(startSec + 1.2))); a.KeyFrames.Add(new LinearDoubleKeyFrame(1, KT(totalSec))); Storyboard.SetTarget(a, p); Storyboard.SetTargetProperty(a, new PropertyPath(UIElement.OpacityProperty)); sb.Children.Add(a); } } private static DoubleAnimationUsingKeyFrames MakeKeyFrameAnim((double val, double sec)[] frames) { var a = new DoubleAnimationUsingKeyFrames(); foreach (var (val, sec) in frames) a.KeyFrames.Add(new LinearDoubleKeyFrame(val, KT(sec))); return a; } private void CenterOnScreen() { var monitor = FormsScreen.FromPoint(FormsCursor.Position); var transform = PresentationSource.FromVisual(this)?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity; var topLeft = transform.Transform(new Point(monitor.WorkingArea.Left, monitor.WorkingArea.Top)); var bottomRight = transform.Transform(new Point(monitor.WorkingArea.Right, monitor.WorkingArea.Bottom)); var screen = new Rect(topLeft, bottomRight); // ActualHeight/ActualWidth는 첫 Show() 전 레이아웃 패스 이전에 0일 수 있음 → 기본값으로 보호 var w = ActualWidth > 0 ? ActualWidth : 640; var h = ActualHeight > 0 ? ActualHeight : 80; Left = screen.Left + (screen.Width - w) / 2; Top = _vm.WindowPosition switch { "center" => screen.Top + (screen.Height - h) / 2, "bottom" => screen.Top + screen.Height * 0.75, _ => screen.Top + screen.Height * 0.2, // "center-top" (기본) }; } private void ApplyInitialPlacement() { if (!TryRestoreRememberedPosition()) CenterOnScreen(); UpdateRememberedPositionCache(); } private bool TryRestoreRememberedPosition() { var launcher = CurrentApp?.SettingsService?.Settings?.Launcher; if (launcher == null || !launcher.RememberPosition) return false; if (launcher.LastLeft < 0 || launcher.LastTop < 0) return false; var rememberPoint = new Point(launcher.LastLeft, launcher.LastTop); if (!IsVisibleOnAnyScreen(rememberPoint)) return false; Left = launcher.LastLeft; Top = launcher.LastTop; return true; } private static bool IsVisibleOnAnyScreen(Point point) { foreach (var screen in FormsScreen.AllScreens) { var bounds = screen.WorkingArea; if (point.X >= bounds.Left && point.X <= bounds.Right - 40 && point.Y >= bounds.Top && point.Y <= bounds.Bottom - 40) { return true; } } return false; } private void UpdateRememberedPositionCache() { var launcher = CurrentApp?.SettingsService?.Settings?.Launcher; if (launcher == null || !launcher.RememberPosition || !IsLoaded) return; launcher.LastLeft = Left; launcher.LastTop = Top; } private void SaveRememberedPosition() { var app = CurrentApp; var settingsService = app?.SettingsService; if (settingsService == null) return; var launcher = settingsService.Settings.Launcher; if (launcher == null || !launcher.RememberPosition || !IsLoaded) return; UpdateRememberedPositionCache(); settingsService.Save(); } // 지원 테마 이름 목록 private static readonly HashSet KnownThemes = new(StringComparer.OrdinalIgnoreCase) { "Dark", "Light", "OLED", "Nord", "Monokai", "Catppuccin", "Sepia", "Alfred", "AlfredLight", "Codex" }; internal void ApplyTheme() => ApplyTheme(_vm.ThemeSetting, _vm.CustomThemeColors); /// /// 테마를 즉시 교체합니다. 설정 창 미리보기에서도 호출됩니다. /// internal void ApplyTheme(string? themeKey, AxCopilot.Models.CustomThemeColors? customColors) { var appDicts = System.Windows.Application.Current.Resources.MergedDictionaries; // 기존 테마 딕셔너리 제거 (Source 기반 또는 커스텀 빌드) var existing = appDicts.FirstOrDefault(d => d.Source?.ToString().Contains("/Themes/") == true || d.Contains("LauncherBackground")); if (existing != null) appDicts.Remove(existing); var setting = (themeKey ?? "system").ToLowerInvariant(); if (setting == "custom" && customColors != null) { appDicts.Add(BuildCustomDictionary(customColors)); UpdateSelectionGlow(); // 커스텀 테마도 AccentColor 적용 return; } var themeName = GetEffectiveThemeName(setting); appDicts.Add(new ResourceDictionary { Source = new Uri($"pack://application:,,,/Themes/{themeName}.xaml") }); UpdateSelectionGlow(); // 테마 변경 시 AccentColor 기반으로 글로우 색 갱신 } private static string GetEffectiveThemeName(string setting) => setting switch { "dark" => "Dark", "light" => "Light", "oled" => "OLED", "nord" => "Nord", "monokai" => "Monokai", "catppuccin" => "Catppuccin", "sepia" => "Sepia", "alfred" => "Alfred", "alfredlight" => "AlfredLight", "codex" => "Codex", _ => IsSystemDarkMode() ? "Dark" : "Light" // "system" 또는 미지원 값 }; private static ResourceDictionary BuildCustomDictionary(CustomThemeColors c) { SolidColorBrush Brush(string hex) { var color = (Color)ColorConverter.ConvertFromString(hex); return new SolidColorBrush(color); } return new ResourceDictionary { { "LauncherBackground", Brush(c.LauncherBackground) }, { "ItemBackground", Brush(c.ItemBackground) }, { "ItemSelectedBackground", Brush(c.ItemSelectedBackground) }, { "ItemSelectedHoverBackground", LightenBrush(Brush(c.ItemSelectedBackground), 0.15) }, { "ItemHoverBackground", Brush(c.ItemHoverBackground) }, { "PrimaryText", Brush(c.PrimaryText) }, { "SecondaryText", Brush(c.SecondaryText) }, { "PlaceholderText", Brush(c.PlaceholderText) }, { "AccentColor", Brush(c.AccentColor) }, { "SeparatorColor", Brush(c.SeparatorColor) }, { "HintBackground", Brush(c.HintBackground) }, { "HintText", Brush(c.HintText) }, { "BorderColor", Brush(c.BorderColor) }, { "ScrollbarThumb", Brush(c.ScrollbarThumb) }, { "ShadowColor", (Color)ColorConverter.ConvertFromString(c.ShadowColor) }, // 커스텀 테마: 사용자가 설정한 라운딩 적용 { "WindowCornerRadius", new CornerRadius(Math.Clamp(c.WindowCornerRadius, 0, 30)) }, { "ItemCornerRadius", new CornerRadius(Math.Clamp(c.ItemCornerRadius, 0, 20)) }, }; } /// SolidColorBrush를 지정 비율만큼 밝게 합니다. private static SolidColorBrush LightenBrush(SolidColorBrush brush, double amount) { var c = brush.Color; byte Clamp(int v) => (byte)Math.Min(255, Math.Max(0, v)); return new SolidColorBrush(Color.FromRgb( Clamp(c.R + (int)(255 * amount)), Clamp(c.G + (int)(255 * amount)), Clamp(c.B + (int)(255 * amount)))); } private static bool IsSystemDarkMode() { try { using var key = Registry.CurrentUser.OpenSubKey( @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); return key?.GetValue("AppsUseLightTheme") is int v && v == 0; } catch { return true; } } private void AnimateIn() { Opacity = 0; var ease = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }; var fadeAnim = new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(100)) { EasingFunction = ease }; var slideAnim = new System.Windows.Media.Animation.DoubleAnimation(-8, 0, TimeSpan.FromMilliseconds(120)) { EasingFunction = ease }; BeginAnimation(OpacityProperty, fadeAnim); // Window에 AllowsTransparency=True 일 때 RenderTransform을 Window에 직접 설정하면 // InvalidOperationException 발생 → Content(루트 Border)에 적용 if (Content is System.Windows.FrameworkElement root) { var translate = new System.Windows.Media.TranslateTransform(0, -10); root.RenderTransform = translate; root.RenderTransformOrigin = new System.Windows.Point(0.5, 0); translate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, slideAnim); } } // ─── IME 보완 검색 ──────────────────────────────────────────────────────── /// /// WPF 바인딩(UpdateSourceTrigger=PropertyChanged)은 한글 IME 조합 중에는 /// ViewModel 업데이트를 지연하므로, TextChanged에서 직접 검색을 트리거합니다. /// InputText 프로퍼티를 건드리지 않아 IME 조합 상태(音節)가 유지됩니다. /// private void InputBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) { // 바인딩이 이미 ViewModel을 업데이트한 경우(조합 완료 후)에는 중복 실행 방지 if (_vm.InputText == InputBox.Text) return; // 조합 중 텍스트로 즉시 검색 — InputText 바인딩 우회 _ = _vm.TriggerImeSearchAsync(InputBox.Text); } // ─── 키보드 이벤트 ──────────────────────────────────────────────────────── /// /// Window 레벨 PreviewKeyDown — 터널링으로 먼저 실행되므로 /// TextBox 내부 ScrollViewer가 Up/Down을 소비하기 전에 인터셉트합니다. /// private void Window_PreviewKeyDown(object sender, KeyEventArgs e) { bool shift = (Keyboard.Modifiers & ModifierKeys.Shift) != 0; switch (e.Key) { case Key.Escape: if (_vm.IsActionMode) _vm.ExitActionMode(); else Hide(); e.Handled = true; break; case Key.Enter: // Ctrl+Enter, Alt+Enter → Window_KeyDown에서 처리 if ((Keyboard.Modifiers & ModifierKeys.Control) != 0 || (Keyboard.Modifiers & ModifierKeys.Alt) != 0) return; if (shift) { if (TryOpenClipboardImagePreview()) { e.Handled = true; break; } // 퍼지 파일 검색 결과: Shift+Enter → 파일이 있는 폴더 열기 if (_vm.SelectedItem?.Data is AxCopilot.Services.IndexEntry shiftEntry) { var expanded = Environment.ExpandEnvironmentVariables(shiftEntry.Path); Hide(); // File.Exists/Directory.Exists 생략 — 탐색기가 없는 경로는 알아서 처리 // 폴더인 경우 바로 열기, 파일인 경우 /select로 위치 표시 _ = Task.Run(() => { try { if (shiftEntry.Type == Services.IndexEntryType.Folder) System.Diagnostics.Process.Start("explorer.exe", $"\"{expanded}\""); else System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{expanded}\""); } catch { } }); } // 캡처 모드: 지연 캡처 타이머 표시 else if (_vm.ActivePrefix != null && _vm.ActivePrefix.Equals("cap", StringComparison.OrdinalIgnoreCase) && _vm.ShowDelayTimerItems()) { // 타이머 선택 목록으로 전환됨 — Enter로 선택 } else if (_vm.MergeCount > 0) _vm.ExecuteMerge(); else ShowLargeType(); } else if (_vm.IsActionMode && TryHandleSpecialAction()) { // 삭제/이름 변경 등 특수 액션 처리됨 — 별도 처리 } else { _ = _vm.ExecuteSelectedAsync(); } e.Handled = true; break; case Key.Down: if (shift) { _vm.ToggleMergeItem(_vm.SelectedItem); _vm.SelectNext(); } else { _vm.SelectNext(); } ScrollToSelected(); e.Handled = true; break; case Key.Up: if (shift) { _vm.ToggleMergeItem(_vm.SelectedItem); _vm.SelectPrev(); } else { _vm.SelectPrev(); } ScrollToSelected(); e.Handled = true; break; case Key.Right: // 커서가 입력 끝에 있고 선택된 항목이 파일/앱이면 액션 서브메뉴 진입 if (InputBox.CaretIndex == InputBox.Text.Length && InputBox.Text.Length > 0 && _vm.CanEnterActionMode()) { _vm.EnterActionMode(_vm.SelectedItem!); e.Handled = true; } break; case Key.PageDown: for (int i = 0; i < 5 && _vm.Results.Count > 0; i++) _vm.SelectNext(); ScrollToSelected(); e.Handled = true; break; case Key.PageUp: for (int i = 0; i < 5 && _vm.Results.Count > 0; i++) _vm.SelectPrev(); ScrollToSelected(); e.Handled = true; break; case Key.Home: // 입력창 커서가 맨 앞이거나 입력이 없을 때 → 목록 첫 항목으로 이동 if (InputBox.CaretIndex == 0 || string.IsNullOrEmpty(InputBox.Text)) { _vm.SelectFirst(); ScrollToSelected(); e.Handled = true; } break; case Key.End: // 입력창 커서가 맨 끝이거나 입력이 없을 때 → 목록 마지막 항목으로 이동 if (InputBox.CaretIndex == InputBox.Text.Length || string.IsNullOrEmpty(InputBox.Text)) { _vm.SelectLast(); ScrollToSelected(); e.Handled = true; } break; case Key.Tab: // 자동완성: 선택된 항목의 Title을 입력창에 채우고 커서를 끝으로 이동 if (_vm.SelectedItem != null) { _vm.InputText = _vm.SelectedItem.Title; // 바인딩 업데이트 후 커서를 끝으로 — Dispatcher로 다음 렌더 사이클에 실행 Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; InputBox.Focus(); }, System.Windows.Threading.DispatcherPriority.Input); } e.Handled = true; break; } } private void Window_KeyDown(object sender, KeyEventArgs e) { var mod = Keyboard.Modifiers; // ─── Ctrl+, → 설정 창 열기 ───────────────────────────────────────── if (e.Key == Key.OemComma && mod == ModifierKeys.Control) { Hide(); OpenSettingsAction?.Invoke(); e.Handled = true; return; } // ─── F1 → 도움말 창 열기 ──────────────────────────────────────────── if (e.Key == Key.F1) { _vm.InputText = "help"; e.Handled = true; return; } // ─── F5 → 인덱스 새로 고침 ────────────────────────────────────────── if (e.Key == Key.F5) { var app = (App)System.Windows.Application.Current; _ = app.IndexService?.BuildAsync(CancellationToken.None); ShowIndexStatus("⟳ 인덱스 재구축 중…", TimeSpan.FromSeconds(8)); e.Handled = true; return; } // ─── Delete → 항목 삭제 ───────────────────────────────────────────── if (e.Key == Key.Delete && mod == ModifierKeys.None) { if (_vm.SelectedItem != null) { var input = _vm.InputText ?? ""; // note 예약어 활성 상태에서 메모 개별 삭제 if (input.StartsWith("note", StringComparison.OrdinalIgnoreCase) && _vm.SelectedItem.Data is string noteContent && noteContent != "__CLEAR__") { var title = _vm.SelectedItem.Title; var result = CustomMessageBox.Show( $"'{title}' 메모를 삭제하시겠습니까?", "AX Copilot", MessageBoxButton.OKCancel, MessageBoxImage.Question); if (result == MessageBoxResult.OK) { Handlers.NoteHandler.DeleteNote(noteContent); // 결과 목록 새로고침 (InputText 재설정으로 SearchAsync 트리거) var current = _vm.InputText ?? ""; _vm.InputText = current + " "; _vm.InputText = current; } } else { var title = _vm.SelectedItem.Title; var result = CustomMessageBox.Show( $"'{title}' 항목을 목록에서 제거하시겠습니까?", "AX Copilot", MessageBoxButton.OKCancel, MessageBoxImage.Question); if (result == MessageBoxResult.OK) _vm.RemoveSelectedFromRecent(); } } e.Handled = true; return; } // ─── Ctrl+L → 입력창 초기화 ───────────────────────────────────────── if (e.Key == Key.L && mod == ModifierKeys.Control) { _vm.ClearInput(); InputBox.Focus(); e.Handled = true; return; } // ─── Ctrl+C → 선택 항목 이름 복사 (결과 선택 시) ──────────────────── if (e.Key == Key.C && mod == ModifierKeys.Control && _vm.SelectedItem?.Data is AxCopilot.Services.IndexEntry) { _vm.CopySelectedPath(); ShowToast("이름 복사됨"); e.Handled = true; return; } // ─── Ctrl+Shift+C → 전체 경로 복사 ────────────────────────────────── if (e.Key == Key.C && mod == (ModifierKeys.Control | ModifierKeys.Shift)) { if (_vm.CopySelectedFullPath()) ShowToast("경로 복사됨"); e.Handled = true; return; } // ─── Ctrl+Shift+E → 파일 탐색기에서 열기 ──────────────────────────── if (e.Key == Key.E && mod == (ModifierKeys.Control | ModifierKeys.Shift)) { if (_vm.OpenSelectedInExplorer()) Hide(); e.Handled = true; return; } // ─── Ctrl+Enter → 관리자 권한 실행 ────────────────────────────────── if (e.Key == Key.Enter && mod == ModifierKeys.Control) { if (_vm.RunSelectedAsAdmin()) Hide(); e.Handled = true; return; } // ─── Alt+Enter → 파일 속성 보기 ───────────────────────────────────── if (e.Key == Key.Enter && mod == ModifierKeys.Alt) { _vm.ShowSelectedProperties(); e.Handled = true; return; } // ─── Ctrl+H → 클립보드 히스토리 ───────────────────────────────────── if (e.Key == Key.H && mod == ModifierKeys.Control) { _vm.InputText = "#"; Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; }, System.Windows.Threading.DispatcherPriority.Input); e.Handled = true; return; } // ─── Ctrl+R → 최근 실행 항목 ──────────────────────────────────────── if (e.Key == Key.R && mod == ModifierKeys.Control) { _vm.InputText = "recent"; Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; }, System.Windows.Threading.DispatcherPriority.Input); e.Handled = true; return; } // ─── Ctrl+B → 즐겨찾기 뷰 토글 (fav 이면 이전 검색으로, 아니면 fav로) ─ if (e.Key == Key.B && mod == ModifierKeys.Control) { if (_vm.InputText.TrimStart().Equals("fav", StringComparison.OrdinalIgnoreCase)) _vm.ClearInput(); else _vm.InputText = "fav"; Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; }, System.Windows.Threading.DispatcherPriority.Input); e.Handled = true; return; } // ─── Ctrl+K → 단축키 도움말 모달 창 ───────────────────────────────── if (e.Key == Key.K && mod == ModifierKeys.Control) { var helpWin = new ShortcutHelpWindow { Owner = this }; helpWin.ShowDialog(); e.Handled = true; return; } // ─── Ctrl+T → 터미널 열기 (선택 항목 경로, 없으면 홈) ──────────────── if (e.Key == Key.T && mod == ModifierKeys.Control) { _vm.OpenSelectedInTerminal(); Hide(); e.Handled = true; return; } // ─── Ctrl+F → 파일 검색 모드 전환 ─────────────────────────────────── if (e.Key == Key.F && mod == ModifierKeys.Control) { // 입력창 초기화 후 파일 타입 필터 힌트 _vm.ClearInput(); Dispatcher.BeginInvoke(() => { InputBox.Focus(); InputBox.CaretIndex = InputBox.Text.Length; }, System.Windows.Threading.DispatcherPriority.Input); e.Handled = true; return; } // ─── Ctrl+P → 클립보드 모드에서 핀 토글 / 일반 모드에서 즐겨찾기 ─── if (e.Key == Key.P && mod == ModifierKeys.Control) { if (_vm.IsClipboardMode && _vm.SelectedItem?.Data is Services.ClipboardEntry clipEntry) { var clipSvc = (System.Windows.Application.Current as App)?.ClipboardHistoryService; clipSvc?.TogglePin(clipEntry); ShowToast(clipEntry.IsPinned ? "클립보드 핀 고정 📌" : "클립보드 핀 해제"); // 검색 결과 갱신 _vm.InputText = _vm.InputText; } else { var result = _vm.ToggleFavorite(); if (result == true) ShowToast("즐겨찾기에 추가됨 ⭐"); else if (result == false) ShowToast("즐겨찾기에서 제거됨"); else ShowToast("파일/폴더 항목을 선택하세요"); } e.Handled = true; return; } // ─── Ctrl+D → 다운로드 폴더 열기 ─────────────────────────────────── if (e.Key == Key.D && mod == ModifierKeys.Control) { _vm.NavigateToDownloads(); Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; }, System.Windows.Threading.DispatcherPriority.Input); e.Handled = true; return; } // ─── Ctrl+W → 런처 창 닫기 ────────────────────────────────────────── if (e.Key == Key.W && mod == ModifierKeys.Control) { Hide(); e.Handled = true; return; } // ─── F2 → 선택 파일 이름 바꾸기 ───────────────────────────────────── if (e.Key == Key.F2) { if (_vm.SelectedItem?.Data is AxCopilot.Services.IndexEntry entry) { var path = Environment.ExpandEnvironmentVariables(entry.Path); _vm.InputText = $"rename {path}"; Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; }, System.Windows.Threading.DispatcherPriority.Input); } e.Handled = true; return; } // ─── F3 → 파일 빠른 미리보기 (QuickLook 토글) ─────────────────────── if (e.Key == Key.F3) { ToggleQuickLook(); e.Handled = true; return; } // ─── F4 → 화면 영역 OCR 즉시 실행 ───────────────────────────────── if (e.Key == Key.F4) { Hide(); _ = Task.Run(async () => { try { var handler = new Handlers.OcrHandler(); var item = new SDK.LauncherItem( "화면 영역 텍스트 추출", "", null, "__ocr_region__"); await handler.ExecuteAsync(item, CancellationToken.None); } catch (Exception ex) { Services.LogService.Error($"F4 OCR 실행 오류: {ex.Message}"); } }); e.Handled = true; return; } // ─── Ctrl+1~9 → n번째 결과 즉시 실행 ─────────────────────────────── if (mod == ModifierKeys.Control) { int num = e.Key switch { Key.D1 => 1, Key.D2 => 2, Key.D3 => 3, Key.D4 => 4, Key.D5 => 5, Key.D6 => 6, Key.D7 => 7, Key.D8 => 8, Key.D9 => 9, _ => 0 }; if (num > 0 && num <= _vm.Results.Count) { _vm.SelectedItem = _vm.Results[num - 1]; _ = _vm.ExecuteSelectedAsync(); Hide(); e.Handled = true; return; } } } /// 단축키 도움말 팝업 private void ShowShortcutHelp() { var lines = new[] { "[ 전역 ]", "Alt+Space AX Commander 열기/닫기", "", "[ 탐색 ]", "↑ / ↓ 결과 이동", "Enter 선택 실행", "Tab 자동완성", "→ 액션 모드", "Escape 닫기 / 뒤로", "", "[ 기능 ]", "F1 도움말", "F2 파일 이름 바꾸기", "F3 파일 빠른 미리보기", "F4 화면 OCR", "F5 인덱스 새로 고침", "Delete 항목 제거", "Ctrl+, 설정", "Ctrl+L 입력 초기화", "Ctrl+C 이름 복사", "Ctrl+H 클립보드 히스토리", "Ctrl+R 최근 실행", "Ctrl+B 즐겨찾기", "Ctrl+K 이 도움말", "Ctrl+1~9 N번째 실행", "Ctrl+Shift+C 경로 복사", "Ctrl+Shift+E 탐색기에서 열기", "Ctrl+Enter 관리자 실행", "Alt+Enter 속성 보기", "Shift+Enter 대형 텍스트 / 클립보드 이미지 미리보기", }; CustomMessageBox.Show( string.Join("\n", lines), "AX Commander — 단축키 도움말", MessageBoxButton.OK, MessageBoxImage.Information); } /// 오버레이 토스트 표시 (페이드인 → 2초 대기 → 페이드아웃) private void ShowToast(string message, string icon = "\uE73E") { ToastText.Text = message; ToastIcon.Text = icon; ToastOverlay.Visibility = Visibility.Visible; ToastOverlay.Opacity = 0; // 페이드인 var fadeIn = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeIn"); fadeIn.Begin(this); _toastTimer?.Stop(); _toastTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; _toastTimer.Tick += (_, _) => { _toastTimer.Stop(); // 페이드아웃 후 Collapsed var fadeOut = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeOut"); EventHandler? onCompleted = null; onCompleted = (__, ___) => { fadeOut.Completed -= onCompleted; ToastOverlay.Visibility = Visibility.Collapsed; }; fadeOut.Completed += onCompleted; fadeOut.Begin(this); }; _toastTimer.Start(); } private void ShowIndexStatus(string message, TimeSpan duration) { IndexStatusText.Text = message; IndexStatusText.Visibility = Visibility.Visible; _indexStatusTimer?.Stop(); _indexStatusTimer = new System.Windows.Threading.DispatcherTimer { Interval = duration }; _indexStatusTimer.Tick += (_, _) => { _indexStatusTimer.Stop(); IndexStatusText.Visibility = Visibility.Collapsed; }; _indexStatusTimer.Start(); } /// /// 액션 모드에서 특수 처리가 필요한 동작(삭제/이름변경)을 처리합니다. /// 처리되면 true 반환 → ExecuteSelectedAsync 호출 생략. /// private bool TryHandleSpecialAction() { if (_vm.SelectedItem?.Data is not AxCopilot.ViewModels.FileActionData actionData) return false; switch (actionData.Action) { case AxCopilot.ViewModels.FileAction.DeleteToRecycleBin: { var path = actionData.Path; var name = System.IO.Path.GetFileName(path); var r = CustomMessageBox.Show( $"'{name}'\n\n이 항목을 휴지통으로 보내겠습니까?", "AX Copilot — 삭제 확인", MessageBoxButton.OKCancel, MessageBoxImage.Warning); if (r == MessageBoxResult.OK) { try { SendToRecycleBin(path); _vm.ExitActionMode(); ShowToast("휴지통으로 이동됨", "\uE74D"); } catch (Exception ex) { CustomMessageBox.Show($"삭제 실패: {ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Error); } } else { _vm.ExitActionMode(); } return true; } case AxCopilot.ViewModels.FileAction.Rename: { var path = actionData.Path; _vm.ExitActionMode(); _vm.InputText = $"rename {path}"; Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; }, System.Windows.Threading.DispatcherPriority.Input); return true; } default: return false; } } // ─── Shell32 휴지통 삭제 ──────────────────────────────────────────────── [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct SHFILEOPSTRUCT { public IntPtr hwnd; public uint wFunc; [MarshalAs(UnmanagedType.LPWStr)] public string pFrom; [MarshalAs(UnmanagedType.LPWStr)] public string? pTo; public ushort fFlags; [MarshalAs(UnmanagedType.Bool)] public bool fAnyOperationsAborted; public IntPtr hNameMappings; [MarshalAs(UnmanagedType.LPWStr)] public string? lpszProgressTitle; } [DllImport("shell32.dll", CharSet = CharSet.Unicode)] private static extern int SHFileOperation(ref SHFILEOPSTRUCT lpFileOp); private const uint FO_DELETE = 0x0003; private const ushort FOF_ALLOWUNDO = 0x0040; private const ushort FOF_NOCONFIRMATION = 0x0010; private const ushort FOF_SILENT = 0x0004; /// 파일·폴더를 Windows 휴지통으로 보냅니다. private void SendToRecycleBin(string path) { // pFrom은 null-terminated + 추가 null 필요 var op = new SHFILEOPSTRUCT { hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle, wFunc = FO_DELETE, pFrom = path + '\0', fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT, }; int result = SHFileOperation(ref op); if (result != 0) throw new System.ComponentModel.Win32Exception(result, $"SHFileOperation 실패 (코드 {result})"); } private void ShowLargeType() { // 클립보드 항목 → 시스템 클립보드에 자동 복사 + 외부 앱에서 열기 if (_vm.SelectedItem?.Data is Services.ClipboardEntry clipEntry) { try { // 자동 클립보드 복사 억제 (히스토리 중복 방지) var app = System.Windows.Application.Current as App; app?.ClipboardHistoryService?.SuppressNextCapture(); if (!clipEntry.IsText && clipEntry.Image != null) { // 원본 이미지가 있으면 원본 사용, 없으면 썸네일 사용 var originalImg = Services.ClipboardHistoryService.LoadOriginalImage(clipEntry.OriginalImagePath); var imgToUse = originalImg ?? clipEntry.Image; // 시스템 클립보드에 원본 복사 Clipboard.SetImage(imgToUse); // 이미지: PNG로 저장 → 기본 이미지 뷰어 string path; if (!string.IsNullOrEmpty(clipEntry.OriginalImagePath) && System.IO.File.Exists(clipEntry.OriginalImagePath)) { path = clipEntry.OriginalImagePath; // 원본 파일 직접 열기 } else { path = Services.TempFileService.CreateTempFile("clip_image", ".png"); var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(imgToUse)); using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create); encoder.Save(fs); } System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); } else if (!string.IsNullOrEmpty(clipEntry.Text)) { // 시스템 클립보드에 텍스트 복사 Clipboard.SetText(clipEntry.Text); // 텍스트: txt로 저장 → 메모장 var path = Services.TempFileService.CreateTempFile("clip_text", ".txt"); System.IO.File.WriteAllText(path, clipEntry.Text, System.Text.Encoding.UTF8); System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("notepad.exe", $"\"{path}\"") { UseShellExecute = true }); } } catch (Exception ex) { Services.LogService.Warn($"클립보드 외부 뷰어 실패: {ex.Message}"); } return; } var text = _vm.GetLargeTypeText(); if (string.IsNullOrWhiteSpace(text)) return; new LargeTypeWindow(text).Show(); } /// 이미 선택된 아이템을 클릭하면 Execute, 아직 선택되지 않은 아이템 클릭은 선택만. private SDK.LauncherItem? _lastClickedItem; private DateTime _lastClickTime; private void ResultList_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { // 클릭한 ListViewItem 찾기 var dep = e.OriginalSource as DependencyObject; while (dep != null && dep is not System.Windows.Controls.ListViewItem) dep = System.Windows.Media.VisualTreeHelper.GetParent(dep); if (dep is not System.Windows.Controls.ListViewItem lvi) return; var clickedItem = lvi.Content as SDK.LauncherItem; if (clickedItem == null) return; var now = DateTime.UtcNow; var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds; if (_lastClickedItem == clickedItem && timeSinceLastClick < 600) { // 같은 아이템을 짧은 간격으로 재클릭 → 액션 모드 또는 실행 if (!_vm.IsActionMode && _vm.CanEnterActionMode()) { _vm.EnterActionMode(clickedItem); e.Handled = true; } else { _ = _vm.ExecuteSelectedAsync(); e.Handled = true; } _lastClickedItem = null; return; } // 첫 번째 클릭 → 선택만 _lastClickedItem = clickedItem; _lastClickTime = now; } private void ResultList_MouseDoubleClick(object sender, MouseButtonEventArgs e) { _ = _vm.ExecuteSelectedAsync(); } private void Window_Deactivated(object sender, EventArgs e) { // 설정 › 기능 › "포커스 잃으면 닫기"가 켜진 경우에만 자동 숨김 if (_vm.CloseOnFocusLost) Hide(); } private void Window_LocationChanged(object sender, EventArgs e) { UpdateRememberedPositionCache(); } private void Window_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) { if (e.NewValue is not bool isVisible) return; if (isVisible) { StartWidgetUpdates(); return; } _quickLookWindow?.Close(); _quickLookWindow = null; StopWidgetUpdates(); SaveRememberedPosition(); } private void ScrollToSelected() { if (_vm.SelectedItem != null) ResultList.ScrollIntoView(_vm.SelectedItem); } private void ShowNotification(string message) { // 시스템 트레이 토스트 알림 표시 // App.xaml.cs의 TrayIcon을 통해 처리 } }