1575 lines
70 KiB
C#
1575 lines
70 KiB
C#
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
|
||
{
|
||
[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;
|
||
|
||
/// <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;
|
||
IndexStatusText.Text = $"✓ {svc.LastIndexCount:N0}개 항목 색인됨 ({svc.LastIndexDuration.TotalSeconds:F1}초)";
|
||
IndexStatusText.Visibility = Visibility.Visible;
|
||
// 기존 타이머 정리 후 5초 후 자동 숨기기
|
||
_indexStatusTimer?.Stop();
|
||
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
|
||
{
|
||
Interval = TimeSpan.FromSeconds(5)
|
||
};
|
||
_indexStatusTimer.Tick += (_, _) => { IndexStatusText.Visibility = Visibility.Collapsed; _indexStatusTimer.Stop(); };
|
||
_indexStatusTimer.Start();
|
||
});
|
||
};
|
||
}
|
||
}
|
||
|
||
private void Window_Loaded(object sender, RoutedEventArgs e)
|
||
{
|
||
CenterOnScreen();
|
||
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();
|
||
CenterOnScreen();
|
||
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 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);
|
||
IndexStatusText.Text = "⟳ 인덱스 재구축 중…";
|
||
IndexStatusText.Visibility = Visibility.Visible;
|
||
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;
|
||
}
|
||
|
||
// ─── 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 파일 이름 바꾸기",
|
||
"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);
|
||
|
||
_indexStatusTimer?.Stop();
|
||
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
|
||
{
|
||
Interval = TimeSpan.FromSeconds(2)
|
||
};
|
||
_indexStatusTimer.Tick += (_, _) =>
|
||
{
|
||
_indexStatusTimer.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);
|
||
};
|
||
_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 ScrollToSelected()
|
||
{
|
||
if (_vm.SelectedItem != null)
|
||
ResultList.ScrollIntoView(_vm.SelectedItem);
|
||
}
|
||
|
||
private void ShowNotification(string message)
|
||
{
|
||
// 시스템 트레이 토스트 알림 표시
|
||
// App.xaml.cs의 TrayIcon을 통해 처리
|
||
}
|
||
}
|