Files
AX-Copilot-Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs
lacvet f7cafe0cfc 런처 Agent Compare 기능 1차 이식 및 현재 런처 구조 연결
- Agent Compare 기준으로 런처 빠른 실행 칩, 검색 히스토리 탐색, 선택 항목 미리보기 패널을 현재 런처에 이식
- 하단 위젯 바, QuickLook(F3), 화면 OCR(F4), 관련 서비스/partial 파일을 현재 LauncherWindow/LauncherViewModel 구조에 연결
- UsageRankingService 상위 항목 조회와 SearchHistoryService를 추가해 실행 상위 경로/검색 기록이 실제 런처 동작에 반영되도록 정리
- README.md, docs/DEVELOPMENT.md에 이식 범위와 검증 결과를 2026-04-05 11:58 (KST) 기준으로 기록

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
2026-04-05 11:51:43 +09:00

1700 lines
73 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>Ctrl+, 단축키로 설정 창을 여는 콜백 (App.xaml.cs에서 주입)</summary>
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;
/// <summary>외부에서 입력 텍스트를 설정합니다 (AI 명령 전달용).</summary>
public void SetInputText(string text)
{
if (InputBox == null) return;
InputBox.Text = text;
InputBox.CaretIndex = text.Length;
// 자동 실행
_vm.InputText = text;
}
/// <summary>
/// 포그라운드 창 전환 제한을 우회하여 확실히 포커스를 가져옵니다.
/// Windows는 현재 포그라운드 스레드가 아닌 스레드에서 SetForegroundWindow를 차단하므로,
/// AttachThreadInput으로 일시적으로 스레드를 연결합니다.
/// </summary>
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();
}
/// <summary>아이콘 애니메이션을 중지하고 모든 픽셀을 완전 점등 상태로 복원합니다.</summary>
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; }
}
/// <summary>10가지 다이아몬드 픽셀 애니메이션 효과 중 랜덤 1개를 적용합니다.</summary>
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);
}
/// <summary>아이콘 클릭 시 다른 랜덤 애니메이션으로 전환.</summary>
private void DiamondIcon_Click(object sender, MouseButtonEventArgs e)
{
if (!_vm.EnableIconAnimation) return;
ApplyRandomIconAnimation();
}
// ─── 무지개 글로우 상시 애니메이션 ────────────────────────────────────
/// <summary>선택 아이템 상시 무지개 글로우 효과를 적용하거나 제거합니다.</summary>
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;
}
}
/// <summary>무지개 글로우를 정지하고 숨깁니다.</summary>
private void StopRainbowGlow()
{
_rainbowTimer?.Stop();
_rainbowTimer = null;
if (RainbowGlowBorder != null) RainbowGlowBorder.Opacity = 0;
}
/// <summary>런처 테두리 무지개 그라데이션 회전을 시작합니다.</summary>
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<string> KnownThemes =
new(StringComparer.OrdinalIgnoreCase)
{ "Dark", "Light", "OLED", "Nord", "Monokai", "Catppuccin", "Sepia", "Alfred", "AlfredLight", "Codex" };
internal void ApplyTheme() =>
ApplyTheme(_vm.ThemeSetting, _vm.CustomThemeColors);
/// <summary>
/// 테마를 즉시 교체합니다. 설정 창 미리보기에서도 호출됩니다.
/// </summary>
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)) },
};
}
/// <summary>SolidColorBrush를 지정 비율만큼 밝게 합니다.</summary>
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 보완 검색 ────────────────────────────────────────────────────────
/// <summary>
/// WPF 바인딩(UpdateSourceTrigger=PropertyChanged)은 한글 IME 조합 중에는
/// ViewModel 업데이트를 지연하므로, TextChanged에서 직접 검색을 트리거합니다.
/// InputText 프로퍼티를 건드리지 않아 IME 조합 상태(音節)가 유지됩니다.
/// </summary>
private void InputBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
{
// 바인딩이 이미 ViewModel을 업데이트한 경우(조합 완료 후)에는 중복 실행 방지
if (_vm.InputText == InputBox.Text) return;
// 조합 중 텍스트로 즉시 검색 — InputText 바인딩 우회
_ = _vm.TriggerImeSearchAsync(InputBox.Text);
}
// ─── 키보드 이벤트 ────────────────────────────────────────────────────────
/// <summary>
/// Window 레벨 PreviewKeyDown — 터널링으로 먼저 실행되므로
/// TextBox 내부 ScrollViewer가 Up/Down을 소비하기 전에 인터셉트합니다.
/// </summary>
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;
}
}
}
/// <summary>단축키 도움말 팝업</summary>
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);
}
/// <summary>오버레이 토스트 표시 (페이드인 → 2초 대기 → 페이드아웃)</summary>
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();
}
/// <summary>
/// 액션 모드에서 특수 처리가 필요한 동작(삭제/이름변경)을 처리합니다.
/// 처리되면 true 반환 → ExecuteSelectedAsync 호출 생략.
/// </summary>
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;
/// <summary>파일·폴더를 Windows 휴지통으로 보냅니다.</summary>
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();
}
/// <summary>이미 선택된 아이템을 클릭하면 Execute, 아직 선택되지 않은 아이템 클릭은 선택만.</summary>
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을 통해 처리
}
}