[Phase L5-1] 항목별 전용 핫키 기능 구현

HotkeyAssignment 모델 추가 (AppSettings.Models.cs):
- Hotkey, Target, Label, Type 필드 (app/url/folder/command)
- AppSettings.CustomHotkeys (List<HotkeyAssignment>) 추가

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:12:32 +09:00
parent e103a3a65a
commit c12e863e3a
13 changed files with 635 additions and 1 deletions

View File

@@ -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()
{

View File

@@ -290,6 +290,19 @@ public partial class SettingsViewModel
// ─── 인덱스 확장자 ──────────────────────────────────────────────────
public ObservableCollection<string> IndexExtensions { get; } = new();
// ─── 전용 핫키 설정 ─────────────────────────────────────────────────────────
public ObservableCollection<HotkeyRowModel> 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<SnippetRowModel> Snippets { get; } = new();

View File

@@ -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 });

View File

@@ -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