using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Data; using System.Windows.Media; using AxCopilot.Core; using AxCopilot.Models; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.ViewModels; public partial class LauncherViewModel : INotifyPropertyChanged { private static App? CurrentApp => System.Windows.Application.Current as App; private readonly CommandResolver _resolver; private readonly SettingsService _settings; private string _inputText = ""; private LauncherItem? _selectedItem; private bool _isLoading; private CancellationTokenSource? _searchCts; private System.Threading.Timer? _debounceTimer; private const int DebounceMs = 30; // 30ms 디바운스 — 연속 입력 시 중간 검색 스킵 private string _lastSearchQuery = ""; // IME 조합 완성 후 동일 쿼리 재검색 방지용 // ─── 파일 액션 모드 ─────────────────────────────────────────────────────── private bool _isActionMode; private LauncherItem? _actionSourceItem; private string _savedQuery = ""; // ─── 클립보드 병합 ──────────────────────────────────────────────────────── private readonly HashSet _mergeQueue = new(); /// /// 검색 결과 컬렉션. ReplaceAll() 호출 시 단일 Reset 이벤트로 WPF 레이아웃 1회만 갱신. /// public BulkObservableCollection Results { get; } = new(); /// /// 그룹핑이 적용된 결과 뷰. prefix 없는 일반 검색에서 앱/폴더/파일/단축키 섹션 구분 표시. /// Group == null 항목은 별도 그룹 헤더 없이 표시됩니다. /// public ICollectionView GroupedResults { get; } // ─── 기본 프로퍼티 ──────────────────────────────────────────────────────── public string InputText { get => _inputText; set { if (_inputText == value) return; _inputText = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasActivePrefix)); OnPropertyChanged(nameof(ActivePrefixLabel)); OnPropertyChanged(nameof(ActivePrefixSymbol)); OnPropertyChanged(nameof(ActivePrefixBrush)); OnPropertyChanged(nameof(IsClipboardMode)); OnPropertyChanged(nameof(ShowMergeHint)); OnPropertyChanged(nameof(MergeHintText)); // 연속 입력 시 이전 검색 즉시 취소 + 50ms 디바운스 후 실제 검색 시작 _searchCts?.Cancel(); _debounceTimer?.Dispose(); if (string.IsNullOrWhiteSpace(value)) { Results.Clear(); } else { var captured = value; _debounceTimer = new System.Threading.Timer( _ => Application.Current?.Dispatcher.InvokeAsync(() => _ = SearchAsync(captured)), null, DebounceMs, System.Threading.Timeout.Infinite); } } } public LauncherItem? SelectedItem { get => _selectedItem; set { _selectedItem = value; OnPropertyChanged(); } } public bool IsLoading { get => _isLoading; set { _isLoading = value; OnPropertyChanged(); } } private string _placeholderText = AxCopilot.Services.L10n.Get("placeholder"); public string PlaceholderText { get => _placeholderText; private set { _placeholderText = value; OnPropertyChanged(); } } /// 런처가 열릴 때마다 호출 — 설정에 따라 랜덤 또는 고정 문구로 교체합니다. public void RefreshPlaceholder() => PlaceholderText = _settings.Settings.Launcher.EnableRandomPlaceholder ? AxCopilot.Services.L10n.GetRandomPlaceholder() : AxCopilot.Services.L10n.Get("placeholder"); /// 아이콘 애니메이션 설정값 public bool EnableIconAnimation => _settings.Settings.Launcher.EnableIconAnimation; /// 런처 무지개 글로우 설정값 public bool EnableRainbowGlow => _settings.Settings.Launcher.EnableRainbowGlow; /// 선택 아이템 글로우 설정값 public bool EnableSelectionGlow => _settings.Settings.Launcher.EnableSelectionGlow; /// 런처 창 테두리 표시 설정값 public bool ShowLauncherBorder => _settings.Settings.Launcher.ShowLauncherBorder; public string ThemeSetting => _settings.Settings.Launcher.Theme; public CustomThemeColors? CustomThemeColors => _settings.Settings.Launcher.CustomTheme; public string WindowPosition => _settings.Settings.Launcher.Position; // ─── Prefix 배지 ───────────────────────────────────────────────────────── private static readonly Dictionary PrefixMap = new() { // ─── 기본 프리픽스 ────────────────────────────────────────────────────── { "@", ("URL", Symbols.Globe, "#0078D4") }, { "~", ("워크", Symbols.Workspace, "#C50F1F") }, { ">", ("명령", Symbols.Terminal, "#323130") }, { "$", ("클립", Symbols.Clipboard, "#8764B8") }, { "cd", ("폴더", Symbols.Folder, "#107C10") }, { "#", ("히스", Symbols.History, "#B7791F") }, { ";", ("스닛", Symbols.Snippet, "#0F6CBD") }, { "=", ("계산", Symbols.Calculator, "#4B5EFC") }, { "!", ("AI", "\uE8BD", "#8B2FC9") }, { "?", ("검색", Symbols.Globe, "#006EAF") }, { "/", ("시스템", Symbols.Power, "#4A4A4A") }, // ─── 시스템 도구 ───────────────────────────────────────────────────────── { "kill ", ("킬", Symbols.Error, "#CC2222") }, { "media ", ("미디어", Symbols.MediaPlay, "#1A6B3C") }, { "info ", ("시스템", Symbols.Computer, "#5B4E7E") }, { "port", ("포트", Symbols.Network, "#006699") }, // ─── v1.5 핸들러 ───────────────────────────────────────────────────────── { "emoji", ("이모지", Symbols.Emoji, "#F59E0B") }, { "color", ("색상", Symbols.ColorPicker, "#EC4899") }, { "recent", ("최근", Symbols.RecentFiles, "#059669") }, { "note", ("메모", Symbols.Note, "#7C3AED") }, { "uninstall", ("제거", Symbols.Uninstall, "#DC2626") }, // ─── v1.6 핸들러 ───────────────────────────────────────────────────────── { "env", ("환경변수", Symbols.EnvVar, "#0D9488") }, { "json", ("JSON", Symbols.JsonValid, "#D97706") }, { "encode ", ("인코딩", Symbols.EncodeIcon, "#6366F1") }, { "snap", ("스냅", Symbols.SnapLayout, "#B45309") }, { "cap", ("캡처", Symbols.CaptureIcon, "#BE185D") }, { "help", ("도움말", Symbols.Info, "#6B7280") }, // ─── Phase L3-5 파일 태그 ────────────────────────────────────────────── { "tag", ("태그", Symbols.Tag, "#6366F1") }, // ─── Phase L3-8 알림 센터 ───────────────────────────────────────────── { "notif", ("알림", Symbols.ReminderBell, "#F59E0B") }, // ─── Phase L3-9 위젯 핸들러 ────────────────────────────────────────── { "pomo", ("타이머", Symbols.Timer, "#F59E0B") }, }; // ─── 설정 기능 토글 (런처 실동작 연결) ────────────────────────────────── /// 번호 배지(1~9) 표시 여부 — LauncherWindow.xaml 번호 뱃지 Visibility 바인딩 public bool ShowNumberBadges => _settings.Settings.Launcher.ShowNumberBadges; /// 포커스 잃으면 런처 닫기 — Window_Deactivated에서 읽음 public bool CloseOnFocusLost => _settings.Settings.Launcher.CloseOnFocusLost; /// 액션 모드(→) 허용 여부 public bool EnableActionMode => _settings.Settings.Launcher.EnableActionMode; /// AI 기능 활성화 여부 (AiEnabled 설정). false이면 ! prefix 숨김. private static bool IsAiEnabled() => CurrentApp?.SettingsService?.Settings.AiEnabled ?? true; public bool HasActivePrefix => _settings.Settings.Launcher.ShowPrefixBadge && _inputText.Length > 0 && PrefixMap.Keys.Any(k => (k != "!" || IsAiEnabled()) && _inputText.StartsWith(k, StringComparison.OrdinalIgnoreCase)); public string? ActivePrefix => PrefixMap.Keys.FirstOrDefault(k => (k != "!" || IsAiEnabled()) && _inputText.StartsWith(k, StringComparison.OrdinalIgnoreCase)); public string ActivePrefixLabel => ActivePrefix != null && PrefixMap.TryGetValue(ActivePrefix, out var info) ? info.Label : ""; public string ActivePrefixSymbol => ActivePrefix != null && PrefixMap.TryGetValue(ActivePrefix, out var info) ? info.Symbol : Symbols.Search; public SolidColorBrush ActivePrefixBrush { get { if (ActivePrefix != null && PrefixMap.TryGetValue(ActivePrefix, out var info)) { var color = (Color)ColorConverter.ConvertFromString(info.ColorHex); return new SolidColorBrush(color); } return new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); } } // ─── 파일 액션 모드 프로퍼티 ───────────────────────────────────────────── public bool IsActionMode { get => _isActionMode; private set { _isActionMode = value; OnPropertyChanged(); OnPropertyChanged(nameof(ShowActionModeBar)); OnPropertyChanged(nameof(ActionModeBreadcrumb)); } } public bool ShowActionModeBar => IsActionMode; public string ActionModeBreadcrumb => _actionSourceItem?.Title ?? ""; /// 현재 선택 항목이 파일 액션 가능 여부 public bool CanEnterActionMode() => !IsActionMode && SelectedItem?.Data is IndexEntry; // ─── 클립보드 병합 모드 프로퍼티 ───────────────────────────────────────── public bool IsClipboardMode => _inputText.StartsWith("#", StringComparison.Ordinal); public int MergeCount => _mergeQueue.Count; public bool ShowMergeHint => _mergeQueue.Count > 0 && IsClipboardMode; public string MergeHintText => _mergeQueue.Count > 0 ? $"{_mergeQueue.Count}개 선택됨 · Shift+Enter로 합치기 · Esc로 취소" : ""; public bool IsItemMarkedForMerge(LauncherItem item) => item.Data is ClipboardEntry e && _mergeQueue.Contains(e); // ─── 이벤트 ─────────────────────────────────────────────────────────────── public event EventHandler? CloseRequested; public event EventHandler? NotificationRequested; public LauncherViewModel(CommandResolver resolver, SettingsService settings) { _resolver = resolver; _settings = settings; // 그룹핑 뷰 초기화 (UI 스레드에서 생성 보장) GroupedResults = CollectionViewSource.GetDefaultView(Results); GroupedResults.GroupDescriptions.Add( new PropertyGroupDescription(nameof(LauncherItem.Group))); } // ─── 런처 표시 시 초기화 ────────────────────────────────────────────────── /// 런처가 표시될 때 호출 — 이전 상태 초기화 public void OnShown() { if (IsActionMode) { IsActionMode = false; _actionSourceItem = null; } Results.Clear(); _lastSearchQuery = ""; ClearMerge(); } // ─── 검색 ──────────────────────────────────────────────────────────────── /// /// IME 조합 중 코드비하인드에서 직접 호출하는 검색 트리거. /// InputText 바인딩을 건드리지 않으므로 한글 조합 상태가 유지됩니다. /// 동일 쿼리로 재호출 시 (IME 조합 완성 이벤트 중복) 검색을 건너뜁니다. /// internal Task TriggerImeSearchAsync(string text) { var key = text.Trim().ToLowerInvariant(); // IME 조합 완성으로 인한 동일 쿼리 재검색이면 선택 위치 유지 (건너뜀) if (key == _lastSearchQuery && Results.Count > 0) return Task.CompletedTask; return SearchAsync(text); } private async Task SearchAsync(string query) { // CTS 취소는 setter에서 이미 처리됨. 새 토큰만 발급. _searchCts = new CancellationTokenSource(); var ct = _searchCts.Token; var queryKey = query.Trim().ToLowerInvariant(); // 동일 쿼리 재검색(IME 커밋 후 WPF 바인딩 갱신 등)이면 결과를 일단 유지 var isSameQuery = queryKey == _lastSearchQuery && Results.Count > 0; if (!isSameQuery) Results.Clear(); if (string.IsNullOrWhiteSpace(query)) { _lastSearchQuery = ""; return; } // 기능 비활성화 시 해당 프리픽스 쿼리는 빈 결과 반환 if (!_settings.Settings.Launcher.EnableFavorites && query.StartsWith("fav", StringComparison.OrdinalIgnoreCase)) return; if (!_settings.Settings.Launcher.EnableRecent && query.StartsWith("recent", StringComparison.OrdinalIgnoreCase)) return; // 동일 쿼리면 현재 선택 항목을 기억해 복원 시도 var prevSelected = isSameQuery ? SelectedItem : null; IsLoading = true; try { var items = await _resolver.ResolveAsync(query, ct); if (ct.IsCancellationRequested) return; // ReplaceAll: Clear+AddRange를 단일 Reset 이벤트로 → WPF 레이아웃 1회만 갱신 Results.ReplaceAll(items); _lastSearchQuery = queryKey; if (isSameQuery && prevSelected != null) { // 결과 목록이 같으면 이전 선택 복원 (ReferenceEquals: 동일 데이터 객체 비교) var restored = Results.FirstOrDefault(r => ReferenceEquals(r.Data, prevSelected.Data) || r.Title == prevSelected.Title); SelectedItem = restored ?? Results.FirstOrDefault(); } else { SelectedItem = Results.FirstOrDefault(); } } catch (OperationCanceledException) { /* 정상 취소 */ } catch (Exception ex) { LogService.Error($"검색 오류: {ex.Message}"); } finally { if (!ct.IsCancellationRequested) IsLoading = false; } } // ─── 실행 ──────────────────────────────────────────────────────────────── public async Task ExecuteSelectedAsync() { // 파일 액션 모드: 선택한 액션 실행 if (IsActionMode && SelectedItem?.Data is FileActionData fileAction) { ExecuteFileAction(fileAction); ExitActionMode(); CloseRequested?.Invoke(this, EventArgs.Empty); return; } if (SelectedItem == null) return; // 창을 먼저 닫아 체감 속도 확보 → 실행은 백그라운드 CloseRequested?.Invoke(this, EventArgs.Empty); try { await _resolver.ExecuteAsync(SelectedItem, InputText, CancellationToken.None); } catch (Exception ex) { NotificationRequested?.Invoke(this, $"실행 실패: {ex.Message}"); LogService.Error($"Execute 오류: {ex.Message}"); } } /// /// 캡처 모드에서 Shift+Enter 시 지연 캡처 타이머 선택 목록을 표시합니다. /// public bool ShowDelayTimerItems() { if (SelectedItem?.Data is not string mode) return false; // 이미 delay 아이템이면 실행으로 넘기기 if (mode.StartsWith("delay:")) return false; // ScreenCaptureHandler 찾기 if (!_resolver.RegisteredHandlers.TryGetValue( ActivePrefix ?? "", out var handler) || handler is not AxCopilot.Handlers.ScreenCaptureHandler capHandler) return false; var items = capHandler.GetDelayItems(mode).ToList(); Results.Clear(); foreach (var item in items) Results.Add(item); SelectedItem = Results.FirstOrDefault(); return true; } public void SelectNext() { if (Results.Count == 0) return; var idx = SelectedItem != null ? Results.IndexOf(SelectedItem) : -1; SelectedItem = Results[(idx + 1) % Results.Count]; } public void SelectPrev() { if (Results.Count == 0) return; var idx = SelectedItem != null ? Results.IndexOf(SelectedItem) : 0; SelectedItem = Results[(idx - 1 + Results.Count) % Results.Count]; } // ─── Large Type ─────────────────────────────────────────────────────────── /// Shift+Enter 시 Large Type으로 표시할 텍스트 반환 public string GetLargeTypeText() { if (SelectedItem == null) return ""; // 계산기: 결과값 문자열 if (SelectedItem.Data is string s && !string.IsNullOrWhiteSpace(s)) return s; // 클립보드 히스토리: 전체 텍스트 if (SelectedItem.Data is ClipboardEntry entry && entry.IsText) return entry.Text; // 기본: 제목 return SelectedItem.Title; } }