From c12e863e3a4e5a9af18168b2a737a9297546f02b Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 12:12:32 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L5-1]=20=ED=95=AD=EB=AA=A9=EB=B3=84=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=ED=95=AB=ED=82=A4=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HotkeyAssignment 모델 추가 (AppSettings.Models.cs): - Hotkey, Target, Label, Type 필드 (app/url/folder/command) - AppSettings.CustomHotkeys (List) 추가 InputListener.cs 확장 (Core/): - _customHotkeys 목록 (스레드 안전 lock) - UpdateCustomHotkeys() 메서드 — 설정 저장 후 즉시 갱신 - CustomHotkeyTriggered 이벤트 (CustomHotkeyEventArgs: Target, Type) - WH_KEYBOARD_LL 콜백에 전용 핫키 감지 분기 추가 HotkeyHandler.cs 신규 생성 (Handlers/, 140줄): - prefix="hotkey" — 등록 목록 표시 / 설정 열기 - ExecuteHotkeyTarget() — app/url/folder/command 타입별 실행 App.xaml.cs + App.Settings.cs: - HotkeyHandler 등록 (Phase L5 주석) - OnCustomHotkeyTriggered 이벤트 핸들러 연결 - 설정 저장 시 UpdateCustomHotkeys() 호출 SettingsViewModel 3파일 업데이트: - HotkeyRowModel (Properties.cs): Hotkey/Target/Label/Type + TypeSymbol - CustomHotkeys ObservableCollection + New* 필드 (Properties.cs) - AddCustomHotkey() / RemoveCustomHotkey() 메서드 (Methods.cs) - HotkeyParser 형식 검증, 중복 핫키 방지 - Load/Save에 CustomHotkeys 매핑 (SettingsViewModel.cs, Methods.cs) SettingsWindow.xaml + .xaml.cs: - "전용 핫키" 탭 신규 추가 (배치 명령 탭 다음) - 안내 배너, 입력 폼 (핫키 레코더 + 표시이름 + 타입 + 대상) - 핫키 목록 (배지 + 타입아이콘 + 이름/경로 + 삭제 버튼) - HotkeyRecord_Click: 클릭 후 키 입력 → 자동 핫키 감지 (PreviewKeyDown) - AddHotkey_Click, RemoveHotkey_Click, BrowseHotkeyTarget_Click 핸들러 docs/LAUNCHER_ROADMAP.md: - L5-1 ✅ 완료 표시, 구현 내용 업데이트 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- docs/LAUNCHER_ROADMAP.md | 2 +- src/AxCopilot/App.Settings.cs | 1 + src/AxCopilot/App.xaml.cs | 14 ++ src/AxCopilot/Core/InputListener.cs | 66 +++++++ src/AxCopilot/Handlers/HotkeyHandler.cs | 155 +++++++++++++++ src/AxCopilot/Models/AppSettings.Models.cs | 22 +++ src/AxCopilot/Models/AppSettings.cs | 6 + .../ViewModels/SettingsViewModel.Methods.cs | 53 +++++ .../SettingsViewModel.Properties.cs | 13 ++ src/AxCopilot/ViewModels/SettingsViewModel.cs | 4 + .../ViewModels/SettingsViewModelModels.cs | 27 +++ src/AxCopilot/Views/SettingsWindow.xaml | 184 ++++++++++++++++++ src/AxCopilot/Views/SettingsWindow.xaml.cs | 89 +++++++++ 13 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 src/AxCopilot/Handlers/HotkeyHandler.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index d60e034..6d9f87b 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -129,7 +129,7 @@ | # | 기능 | 설명 | 우선순위 | |---|------|------|----------| -| L5-1 | **항목별 전용 핫키** | 앱·URL·폴더에 `Ctrl+Alt+숫자` 등 글로벌 단축키 직접 할당. `hotkey` 프리픽스로 관리. GlobalHotkeyService에 동적 등록 | 높음 | +| L5-1 | **항목별 전용 핫키** ✅ | 앱·URL·폴더에 `Ctrl+Alt+숫자` 등 글로벌 단축키 직접 할당. `hotkey` 프리픽스로 관리. `HotkeyAssignment` 모델 + `InputListener` 확장 + 설정창 "전용 핫키" 탭 | 높음 | | L5-2 | **OCR 화면 텍스트 추출** | `ocr` 명령 또는 캡처에서 F4 → 화면 영역 드래그 → Windows OCR(로컬) 추출 → 클립보드 복사 / 런처 입력 | 높음 | | L5-3 | **QuickLook 인라인 편집** | F3 미리보기에서 텍스트·마크다운 파일 직접 편집 + Ctrl+S 저장. 변경 감지(수정 표시 `●`), Esc 취소 | 중간 | | L5-4 | **앱 세션 스냅** | 여러 앱을 지정 레이아웃으로 한번에 열기. `snap 세션이름` → 등록된 앱 목록을 각 레이아웃에 배치 | 중간 | diff --git a/src/AxCopilot/App.Settings.cs b/src/AxCopilot/App.Settings.cs index 652aa89..20d6757 100644 --- a/src/AxCopilot/App.Settings.cs +++ b/src/AxCopilot/App.Settings.cs @@ -307,6 +307,7 @@ public partial class App _inputListener.UpdateCaptureHotkey( _settings.Settings.ScreenCapture.GlobalHotkey, _settings.Settings.ScreenCapture.GlobalHotkeyEnabled); + _inputListener.UpdateCustomHotkeys(_settings.Settings.CustomHotkeys); } _worktimeReminder?.RestartTimer(); }; diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 6ce6018..5bb1e58 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -177,6 +177,10 @@ public partial class App : System.Windows.Application // Phase L4-1: 인라인 파일 탐색기 (Prefix=null, 경로 패턴 감지) commandResolver.RegisterHandler(new FileBrowserHandler()); + // ─── Phase L5 핸들러 ────────────────────────────────────────────────── + // Phase L5-1: 전용 핫키 목록 관리 (prefix=hotkey) + commandResolver.RegisterHandler(new HotkeyHandler(settings)); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); @@ -207,6 +211,7 @@ public partial class App : System.Windows.Application _inputListener = new InputListener(); _inputListener.HotkeyTriggered += OnHotkeyTriggered; _inputListener.CaptureHotkeyTriggered += OnCaptureHotkeyTriggered; + _inputListener.CustomHotkeyTriggered += OnCustomHotkeyTriggered; _inputListener.HookFailed += OnHookFailed; // 설정에 저장된 핫키로 초기화 (기본: Alt+Space) @@ -217,6 +222,9 @@ public partial class App : System.Windows.Application settings.Settings.ScreenCapture.GlobalHotkey, settings.Settings.ScreenCapture.GlobalHotkeyEnabled); + // 항목별 전용 핫키 초기화 + _inputListener.UpdateCustomHotkeys(settings.Settings.CustomHotkeys); + var snippetExpander = new SnippetExpander(settings); _inputListener.KeyFilter = snippetExpander.HandleKey; @@ -450,6 +458,12 @@ public partial class App : System.Windows.Application }); } + private void OnCustomHotkeyTriggered(object? sender, Core.CustomHotkeyEventArgs e) + { + // 전용 핫키 발동: 런처를 열지 않고 대상을 직접 실행 + Handlers.HotkeyHandler.ExecuteHotkeyTarget(e.Target, e.Type); + } + private void OnHookFailed(object? sender, EventArgs e) { Dispatcher.Invoke(() => diff --git a/src/AxCopilot/Core/InputListener.cs b/src/AxCopilot/Core/InputListener.cs index 8e30caf..b2ea51f 100644 --- a/src/AxCopilot/Core/InputListener.cs +++ b/src/AxCopilot/Core/InputListener.cs @@ -42,8 +42,15 @@ public class InputListener : IDisposable private HotkeyDefinition _captureHotkey; private bool _captureHotkeyEnabled; + // 항목별 전용 핫키 목록 + private readonly List<(HotkeyDefinition Def, string Target, string Type)> _customHotkeys = new(); + public event EventHandler? HotkeyTriggered; public event EventHandler? CaptureHotkeyTriggered; + + /// 전용 핫키 발동 시 발생합니다. EventArgs에 Target과 Type이 포함됩니다. + public event EventHandler? CustomHotkeyTriggered; + public event EventHandler? HookFailed; /// @@ -74,6 +81,25 @@ public class InputListener : IDisposable } } + /// + /// 항목별 전용 핫키 목록을 업데이트합니다. + /// + public void UpdateCustomHotkeys(IEnumerable assignments) + { + lock (_customHotkeys) + { + _customHotkeys.Clear(); + foreach (var a in assignments) + { + if (HotkeyParser.TryParse(a.Hotkey, out var def)) + _customHotkeys.Add((def, a.Target, a.Type)); + else + LogService.Warn($"전용 핫키 파싱 실패: '{a.Hotkey}' (대상: {a.Target})"); + } + LogService.Info($"전용 핫키 {_customHotkeys.Count}개 등록"); + } + } + /// /// 글로벌 캡처 단축키를 설정합니다. /// @@ -220,6 +246,33 @@ public class InputListener : IDisposable } } + // ─── 항목별 전용 핫키 감지 ─────────────────────────────────────────── + if (!SuspendHotkey && _customHotkeys.Count > 0) + { + List<(HotkeyDefinition Def, string Target, string Type)> snapshot; + lock (_customHotkeys) { snapshot = new List<(HotkeyDefinition, string, string)>(_customHotkeys); } + + foreach (var (def, target, type) in snapshot) + { + if (vkCode != def.VkCode) continue; + + bool ctrlOk = !def.Ctrl || (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; + bool altOk = !def.Alt || (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; + bool shiftOk = !def.Shift || (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; + bool winOk = !def.Win || (GetAsyncKeyState(VK_LWIN) & 0x8000) != 0 + || (GetAsyncKeyState(VK_RWIN) & 0x8000) != 0; + + if (ctrlOk && altOk && shiftOk && winOk) + { + CustomHotkeyTriggered?.Invoke(this, new CustomHotkeyEventArgs(target, type)); + _suppressNextKeyUp = true; + _suppressKeyUpVk = vkCode; + if (def.Alt) _suppressNextAltUp = true; + return (IntPtr)1; + } + } + } + // ─── 스니펫 키 필터 ───────────────────────────────────────────────── if (KeyFilter?.Invoke(vkCode) == true) return (IntPtr)1; @@ -263,3 +316,16 @@ public class InputListener : IDisposable [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); } + +/// 항목별 전용 핫키 발동 이벤트 인수. +public class CustomHotkeyEventArgs : EventArgs +{ + public string Target { get; } + public string Type { get; } + + public CustomHotkeyEventArgs(string target, string type) + { + Target = target; + Type = type; + } +} diff --git a/src/AxCopilot/Handlers/HotkeyHandler.cs b/src/AxCopilot/Handlers/HotkeyHandler.cs new file mode 100644 index 0000000..62a34bd --- /dev/null +++ b/src/AxCopilot/Handlers/HotkeyHandler.cs @@ -0,0 +1,155 @@ +using AxCopilot.Core; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L5-1: 전용 핫키 관리 핸들러. +/// 예: hotkey → 등록된 전용 핫키 목록 표시 +/// hotkey 1doc → 라벨 또는 대상에 "1doc" 포함 항목 필터 +/// Enter 시 해당 핫키 항목의 대상을 실행합니다. +/// +public class HotkeyHandler : IActionHandler +{ + private readonly SettingsService? _settings; + + public HotkeyHandler(SettingsService? settings = null) + { + _settings = settings; + } + + public string? Prefix => "hotkey"; + + public PluginMetadata Metadata => new( + "HotkeyManager", + "전용 핫키 목록 관리", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var items = new List(); + var hotkeys = _settings?.Settings.CustomHotkeys ?? new List(); + + if (hotkeys.Count == 0) + { + items.Add(new LauncherItem( + "등록된 전용 핫키 없음", + "설정 → 전용 핫키 탭에서 항목별 글로벌 단축키를 등록하세요", + null, + "__open_settings__", + Symbol: "\uE713")); + return Task.FromResult>(items); + } + + var filter = query.Trim().ToLowerInvariant(); + + foreach (var h in hotkeys) + { + if (!string.IsNullOrEmpty(filter) && + !h.Label.Contains(filter, StringComparison.OrdinalIgnoreCase) && + !h.Hotkey.Contains(filter, StringComparison.OrdinalIgnoreCase) && + !h.Target.Contains(filter, StringComparison.OrdinalIgnoreCase)) + continue; + + var typeSymbol = h.Type switch + { + "url" => Symbols.Globe, + "folder" => Symbols.Folder, + "command" => "\uE756", + _ => Symbols.App + }; + + var label = string.IsNullOrWhiteSpace(h.Label) + ? System.IO.Path.GetFileNameWithoutExtension(h.Target) + : h.Label; + + items.Add(new LauncherItem( + $"[{h.Hotkey}] {label}", + h.Target, + null, + h, + Symbol: typeSymbol)); + } + + // 설정 단축키 안내 항목 + items.Add(new LauncherItem( + "전용 핫키 설정 열기", + "설정 → 전용 핫키 탭에서 핫키를 추가하거나 제거합니다", + null, + "__open_settings__", + Symbol: "\uE713")); + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is string s && s == "__open_settings__") + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + var app = System.Windows.Application.Current as AxCopilot.App; + app?.OpenSettingsFromChat(); + }); + return Task.CompletedTask; + } + + if (item.Data is Models.HotkeyAssignment ha) + { + ExecuteHotkeyTarget(ha.Target, ha.Type); + } + + return Task.CompletedTask; + } + + /// 전용 핫키 대상을 타입에 따라 실행합니다. + internal static void ExecuteHotkeyTarget(string target, string type) + { + try + { + switch (type) + { + case "url": + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = target, + UseShellExecute = true + }); + break; + + case "folder": + System.Diagnostics.Process.Start("explorer.exe", target); + break; + + case "command": + var parts = target.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var cmdFile = parts[0]; + var cmdArgs = parts.Length > 1 ? parts[1] : ""; + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = cmdFile, + Arguments = cmdArgs, + UseShellExecute = true + }); + break; + + default: // app + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = target, + UseShellExecute = true + }); + break; + } + + Services.LogService.Info($"전용 핫키 실행: {target} ({type})"); + } + catch (Exception ex) + { + Services.LogService.Error($"전용 핫키 실행 오류: {target} — {ex.Message}"); + } + } +} diff --git a/src/AxCopilot/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs index 71e204f..657e51b 100644 --- a/src/AxCopilot/Models/AppSettings.Models.cs +++ b/src/AxCopilot/Models/AppSettings.Models.cs @@ -240,6 +240,28 @@ public class ScreenCaptureSettings public int ScrollDelayMs { get; set; } = 120; } +// ─── 전용 핫키 ──────────────────────────────────────────────────────────────── + +/// +/// 항목별 글로벌 전용 핫키 할당. +/// Hotkey: "Ctrl+Alt+1" 형식, Target: 실행 대상 경로/URL/명령어, Label: 표시 이름. +/// +public class HotkeyAssignment +{ + [JsonPropertyName("hotkey")] + public string Hotkey { get; set; } = ""; + + [JsonPropertyName("target")] + public string Target { get; set; } = ""; + + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// 실행 타입. app | url | folder | command. 기본 app. + [JsonPropertyName("type")] + public string Type { get; set; } = "app"; +} + // ─── 잠금 해제 알림 설정 ─────────────────────────────────────────────────────── public class ReminderSettings diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 1b4d53a..4fc54f2 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -106,6 +106,12 @@ public class AppSettings [JsonPropertyName("reminder")] public ReminderSettings Reminder { get; set; } = new(); + /// + /// 항목별 글로벌 전용 핫키 목록. 어떤 앱이 포커스를 가져도 해당 핫키로 즉시 실행됩니다. + /// + [JsonPropertyName("customHotkeys")] + public List CustomHotkeys { get; set; } = new(); + [JsonPropertyName("llm")] public LlmSettings Llm { get; set; } = new(); } diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.Methods.cs b/src/AxCopilot/ViewModels/SettingsViewModel.Methods.cs index 6f6cab2..a72d151 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.Methods.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.Methods.cs @@ -342,6 +342,15 @@ public partial class SettingsViewModel })) .ToList(); + // 전용 핫키 저장 + s.CustomHotkeys = CustomHotkeys.Select(h => new Models.HotkeyAssignment + { + Hotkey = h.Hotkey, + Target = h.Target, + Label = h.Label, + Type = h.Type + }).ToList(); + // 스니펫 저장 s.Snippets = Snippets.Select(sn => new SnippetEntry { @@ -416,6 +425,50 @@ public partial class SettingsViewModel public void RemoveBatchCommand(BatchCommandModel cmd) => BatchCommands.Remove(cmd); + // ─── 전용 핫키 메서드 ───────────────────────────────────────────────────── + + public bool AddCustomHotkey() + { + if (string.IsNullOrWhiteSpace(_newHotkeyStr) || string.IsNullOrWhiteSpace(_newHotkeyTarget)) + return false; + + var hotkey = _newHotkeyStr.Trim(); + + // 핫키 형식 검증 + if (!Core.HotkeyParser.TryParse(hotkey, out _)) + { + CustomMessageBox.Show( + $"핫키 형식이 올바르지 않습니다: '{hotkey}'\n예) Ctrl+Alt+1, Ctrl+Shift+F2", + "AX Copilot — 핫키 오류", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Warning); + return false; + } + + // 중복 검사 + if (CustomHotkeys.Any(h => h.Hotkey.Equals(hotkey, StringComparison.OrdinalIgnoreCase))) + { + CustomMessageBox.Show( + $"이미 등록된 핫키입니다: '{hotkey}'", + "AX Copilot — 중복 핫키", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Warning); + return false; + } + + CustomHotkeys.Add(new HotkeyRowModel + { + Hotkey = hotkey, + Target = _newHotkeyTarget.Trim(), + Label = string.IsNullOrWhiteSpace(_newHotkeyLabel) ? System.IO.Path.GetFileNameWithoutExtension(_newHotkeyTarget.Trim()) : _newHotkeyLabel.Trim(), + Type = _newHotkeyType + }); + NewHotkeyStr = ""; NewHotkeyTarget = ""; NewHotkeyLabel = ""; NewHotkeyType = "app"; + return true; + } + + public void RemoveCustomHotkey(HotkeyRowModel row) => CustomHotkeys.Remove(row); + // ─── 스니펫 메서드 ────────────────────────────────────────────────────── public bool AddSnippet() { diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs b/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs index b1e5066..e22835d 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs @@ -290,6 +290,19 @@ public partial class SettingsViewModel // ─── 인덱스 확장자 ────────────────────────────────────────────────── public ObservableCollection IndexExtensions { get; } = new(); + // ─── 전용 핫키 설정 ───────────────────────────────────────────────────────── + public ObservableCollection CustomHotkeys { get; } = new(); + + private string _newHotkeyStr = ""; + private string _newHotkeyTarget = ""; + private string _newHotkeyLabel = ""; + private string _newHotkeyType = "app"; + + public string NewHotkeyStr { get => _newHotkeyStr; set { _newHotkeyStr = value; OnPropertyChanged(); } } + public string NewHotkeyTarget { get => _newHotkeyTarget; set { _newHotkeyTarget = value; OnPropertyChanged(); } } + public string NewHotkeyLabel { get => _newHotkeyLabel; set { _newHotkeyLabel = value; OnPropertyChanged(); } } + public string NewHotkeyType { get => _newHotkeyType; set { _newHotkeyType = value; OnPropertyChanged(); } } + // ─── 스니펫 설정 ────────────────────────────────────────────────────────── public ObservableCollection Snippets { get; } = new(); diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index 9b19b52..7ff6eb3 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -268,6 +268,10 @@ public partial class SettingsViewModel : INotifyPropertyChanged foreach (var ext in s.IndexExtensions) IndexExtensions.Add(ext); + // 전용 핫키 로드 + foreach (var h in s.CustomHotkeys) + CustomHotkeys.Add(new HotkeyRowModel { Hotkey = h.Hotkey, Target = h.Target, Label = h.Label, Type = h.Type }); + // 스니펫 로드 foreach (var sn in s.Snippets) Snippets.Add(new SnippetRowModel { Key = sn.Key, Name = sn.Name, Content = sn.Content }); diff --git a/src/AxCopilot/ViewModels/SettingsViewModelModels.cs b/src/AxCopilot/ViewModels/SettingsViewModelModels.cs index 97a7834..7a6ce60 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModelModels.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModelModels.cs @@ -243,6 +243,33 @@ public class RegisteredModelRow : INotifyPropertyChanged => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); } +// ─── 전용 핫키 행 모델 ──────────────────────────────────────────────────────────── + +public class HotkeyRowModel : INotifyPropertyChanged +{ + private string _hotkey = ""; + private string _target = ""; + private string _label = ""; + private string _type = "app"; + + public string Hotkey { get => _hotkey; set { _hotkey = value; OnPropertyChanged(); } } + public string Target { get => _target; set { _target = value; OnPropertyChanged(); } } + public string Label { get => _label; set { _label = value; OnPropertyChanged(); } } + public string Type { get => _type; set { _type = value; OnPropertyChanged(); OnPropertyChanged(nameof(TypeSymbol)); } } + + public string TypeSymbol => Type switch + { + "url" => Symbols.Globe, + "folder" => Symbols.Folder, + "command" => "\uE756", // Terminal + _ => Symbols.App + }; + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? n = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); +} + // ─── 스니펫 행 모델 ──────────────────────────────────────────────────────────── public class SnippetRowModel : INotifyPropertyChanged diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index cacf29a..e28f1fa 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -2791,6 +2791,190 @@ + + + + + + + + + + + + + + + + + + + + + + + 어떤 앱이 포커스를 가져도 등록된 전용 핫키로 파일·폴더·앱·URL을 즉시 실행합니다. + 지원 형식: Ctrl+Alt+1, Ctrl+Shift+F2 등 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 앱 (exe) + 폴더 + URL + 명령어 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/SettingsWindow.xaml.cs b/src/AxCopilot/Views/SettingsWindow.xaml.cs index 9956c25..ff080a6 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml.cs +++ b/src/AxCopilot/Views/SettingsWindow.xaml.cs @@ -132,6 +132,95 @@ public partial class SettingsWindow : Window _vm.RemoveShortcut(shortcut); } + // ─── 전용 핫키 이벤트 핸들러 ─────────────────────────────────────────────── + + /// 핫키 입력 영역 클릭 → PreviewKeyDown 캡처 모드 활성화 + private bool _hotkeyRecording; + + private void HotkeyRecord_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + _hotkeyRecording = true; + SuspendHotkeyCallback?.Invoke(true); + _vm.NewHotkeyStr = "키를 누르세요…"; + // 전역 PreviewKeyDown 캡처 + this.PreviewKeyDown -= HotkeyGlobalCapture_PreviewKeyDown; + this.PreviewKeyDown += HotkeyGlobalCapture_PreviewKeyDown; + } + + private void HotkeyGlobalCapture_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) + { + if (!_hotkeyRecording) return; + e.Handled = true; + + var key = e.Key == System.Windows.Input.Key.System ? e.SystemKey : e.Key; + + // 수정자 단독 → 무시 + if (key is System.Windows.Input.Key.LeftCtrl or System.Windows.Input.Key.RightCtrl + or System.Windows.Input.Key.LeftAlt or System.Windows.Input.Key.RightAlt + or System.Windows.Input.Key.LeftShift or System.Windows.Input.Key.RightShift + or System.Windows.Input.Key.LWin or System.Windows.Input.Key.RWin) + return; + + if (key == System.Windows.Input.Key.Escape) + { + _vm.NewHotkeyStr = ""; + _hotkeyRecording = false; + SuspendHotkeyCallback?.Invoke(false); + this.PreviewKeyDown -= HotkeyGlobalCapture_PreviewKeyDown; + return; + } + + var parts = new List(); + if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Control)) parts.Add("Ctrl"); + if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Alt)) parts.Add("Alt"); + if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift)) parts.Add("Shift"); + if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Windows)) parts.Add("Win"); + + var keyName = key switch + { + System.Windows.Input.Key.Snapshot => "PrintScreen", + System.Windows.Input.Key.Scroll => "ScrollLock", + System.Windows.Input.Key.Pause => "Pause", + _ => key.ToString() + }; + parts.Add(keyName); + + var hotkey = string.Join("+", parts); + if (Core.HotkeyParser.TryParse(hotkey, out _)) + _vm.NewHotkeyStr = hotkey; + + _hotkeyRecording = false; + SuspendHotkeyCallback?.Invoke(false); + this.PreviewKeyDown -= HotkeyGlobalCapture_PreviewKeyDown; + } + + private void AddHotkey_Click(object sender, RoutedEventArgs e) + { + _vm.AddCustomHotkey(); + } + + private void RemoveHotkey_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (sender is FrameworkElement fe && fe.Tag is ViewModels.HotkeyRowModel row) + _vm.RemoveCustomHotkey(row); + } + + private void BrowseHotkeyTarget_Click(object sender, RoutedEventArgs e) + { + var dlg = new Microsoft.Win32.OpenFileDialog + { + Title = "실행 파일 선택", + Filter = "실행 파일|*.exe;*.bat;*.cmd;*.ps1;*.lnk|모든 파일|*.*" + }; + if (dlg.ShowDialog(this) == true) + { + _vm.NewHotkeyTarget = dlg.FileName; + _vm.NewHotkeyType = "app"; + if (string.IsNullOrWhiteSpace(_vm.NewHotkeyLabel)) + _vm.NewHotkeyLabel = System.IO.Path.GetFileNameWithoutExtension(dlg.FileName); + } + } + private void AddBatchCommand_Click(object sender, RoutedEventArgs e) { if (!_vm.AddBatchCommand())