[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

@@ -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-2 | **OCR 화면 텍스트 추출** | `ocr` 명령 또는 캡처에서 F4 → 화면 영역 드래그 → Windows OCR(로컬) 추출 → 클립보드 복사 / 런처 입력 | 높음 |
| L5-3 | **QuickLook 인라인 편집** | F3 미리보기에서 텍스트·마크다운 파일 직접 편집 + Ctrl+S 저장. 변경 감지(수정 표시 `●`), Esc 취소 | 중간 | | L5-3 | **QuickLook 인라인 편집** | F3 미리보기에서 텍스트·마크다운 파일 직접 편집 + Ctrl+S 저장. 변경 감지(수정 표시 `●`), Esc 취소 | 중간 |
| L5-4 | **앱 세션 스냅** | 여러 앱을 지정 레이아웃으로 한번에 열기. `snap 세션이름` → 등록된 앱 목록을 각 레이아웃에 배치 | 중간 | | L5-4 | **앱 세션 스냅** | 여러 앱을 지정 레이아웃으로 한번에 열기. `snap 세션이름` → 등록된 앱 목록을 각 레이아웃에 배치 | 중간 |

View File

@@ -307,6 +307,7 @@ public partial class App
_inputListener.UpdateCaptureHotkey( _inputListener.UpdateCaptureHotkey(
_settings.Settings.ScreenCapture.GlobalHotkey, _settings.Settings.ScreenCapture.GlobalHotkey,
_settings.Settings.ScreenCapture.GlobalHotkeyEnabled); _settings.Settings.ScreenCapture.GlobalHotkeyEnabled);
_inputListener.UpdateCustomHotkeys(_settings.Settings.CustomHotkeys);
} }
_worktimeReminder?.RestartTimer(); _worktimeReminder?.RestartTimer();
}; };

View File

@@ -177,6 +177,10 @@ public partial class App : System.Windows.Application
// Phase L4-1: 인라인 파일 탐색기 (Prefix=null, 경로 패턴 감지) // Phase L4-1: 인라인 파일 탐색기 (Prefix=null, 경로 패턴 감지)
commandResolver.RegisterHandler(new FileBrowserHandler()); commandResolver.RegisterHandler(new FileBrowserHandler());
// ─── Phase L5 핸들러 ──────────────────────────────────────────────────
// Phase L5-1: 전용 핫키 목록 관리 (prefix=hotkey)
commandResolver.RegisterHandler(new HotkeyHandler(settings));
// ─── 플러그인 로드 ──────────────────────────────────────────────────── // ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver); var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll(); pluginHost.LoadAll();
@@ -207,6 +211,7 @@ public partial class App : System.Windows.Application
_inputListener = new InputListener(); _inputListener = new InputListener();
_inputListener.HotkeyTriggered += OnHotkeyTriggered; _inputListener.HotkeyTriggered += OnHotkeyTriggered;
_inputListener.CaptureHotkeyTriggered += OnCaptureHotkeyTriggered; _inputListener.CaptureHotkeyTriggered += OnCaptureHotkeyTriggered;
_inputListener.CustomHotkeyTriggered += OnCustomHotkeyTriggered;
_inputListener.HookFailed += OnHookFailed; _inputListener.HookFailed += OnHookFailed;
// 설정에 저장된 핫키로 초기화 (기본: Alt+Space) // 설정에 저장된 핫키로 초기화 (기본: Alt+Space)
@@ -217,6 +222,9 @@ public partial class App : System.Windows.Application
settings.Settings.ScreenCapture.GlobalHotkey, settings.Settings.ScreenCapture.GlobalHotkey,
settings.Settings.ScreenCapture.GlobalHotkeyEnabled); settings.Settings.ScreenCapture.GlobalHotkeyEnabled);
// 항목별 전용 핫키 초기화
_inputListener.UpdateCustomHotkeys(settings.Settings.CustomHotkeys);
var snippetExpander = new SnippetExpander(settings); var snippetExpander = new SnippetExpander(settings);
_inputListener.KeyFilter = snippetExpander.HandleKey; _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) private void OnHookFailed(object? sender, EventArgs e)
{ {
Dispatcher.Invoke(() => Dispatcher.Invoke(() =>

View File

@@ -42,8 +42,15 @@ public class InputListener : IDisposable
private HotkeyDefinition _captureHotkey; private HotkeyDefinition _captureHotkey;
private bool _captureHotkeyEnabled; private bool _captureHotkeyEnabled;
// 항목별 전용 핫키 목록
private readonly List<(HotkeyDefinition Def, string Target, string Type)> _customHotkeys = new();
public event EventHandler? HotkeyTriggered; public event EventHandler? HotkeyTriggered;
public event EventHandler? CaptureHotkeyTriggered; public event EventHandler? CaptureHotkeyTriggered;
/// <summary>전용 핫키 발동 시 발생합니다. EventArgs에 Target과 Type이 포함됩니다.</summary>
public event EventHandler<CustomHotkeyEventArgs>? CustomHotkeyTriggered;
public event EventHandler? HookFailed; public event EventHandler? HookFailed;
/// <summary> /// <summary>
@@ -74,6 +81,25 @@ public class InputListener : IDisposable
} }
} }
/// <summary>
/// 항목별 전용 핫키 목록을 업데이트합니다.
/// </summary>
public void UpdateCustomHotkeys(IEnumerable<Models.HotkeyAssignment> 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}개 등록");
}
}
/// <summary> /// <summary>
/// 글로벌 캡처 단축키를 설정합니다. /// 글로벌 캡처 단축키를 설정합니다.
/// </summary> /// </summary>
@@ -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) if (KeyFilter?.Invoke(vkCode) == true)
return (IntPtr)1; return (IntPtr)1;
@@ -263,3 +316,16 @@ public class InputListener : IDisposable
[DllImport("user32.dll", CharSet = CharSet.Unicode)] [DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
} }
/// <summary>항목별 전용 핫키 발동 이벤트 인수.</summary>
public class CustomHotkeyEventArgs : EventArgs
{
public string Target { get; }
public string Type { get; }
public CustomHotkeyEventArgs(string target, string type)
{
Target = target;
Type = type;
}
}

View File

@@ -0,0 +1,155 @@
using AxCopilot.Core;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L5-1: 전용 핫키 관리 핸들러.
/// 예: hotkey → 등록된 전용 핫키 목록 표시
/// hotkey 1doc → 라벨 또는 대상에 "1doc" 포함 항목 필터
/// Enter 시 해당 핫키 항목의 대상을 실행합니다.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var items = new List<LauncherItem>();
var hotkeys = _settings?.Settings.CustomHotkeys ?? new List<Models.HotkeyAssignment>();
if (hotkeys.Count == 0)
{
items.Add(new LauncherItem(
"등록된 전용 핫키 없음",
"설정 → 전용 핫키 탭에서 항목별 글로벌 단축키를 등록하세요",
null,
"__open_settings__",
Symbol: "\uE713"));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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;
}
/// <summary>전용 핫키 대상을 타입에 따라 실행합니다.</summary>
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}");
}
}
}

View File

@@ -240,6 +240,28 @@ public class ScreenCaptureSettings
public int ScrollDelayMs { get; set; } = 120; public int ScrollDelayMs { get; set; } = 120;
} }
// ─── 전용 핫키 ────────────────────────────────────────────────────────────────
/// <summary>
/// 항목별 글로벌 전용 핫키 할당.
/// Hotkey: "Ctrl+Alt+1" 형식, Target: 실행 대상 경로/URL/명령어, Label: 표시 이름.
/// </summary>
public class HotkeyAssignment
{
[JsonPropertyName("hotkey")]
public string Hotkey { get; set; } = "";
[JsonPropertyName("target")]
public string Target { get; set; } = "";
[JsonPropertyName("label")]
public string Label { get; set; } = "";
/// <summary>실행 타입. app | url | folder | command. 기본 app.</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "app";
}
// ─── 잠금 해제 알림 설정 ─────────────────────────────────────────────────────── // ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
public class ReminderSettings public class ReminderSettings

View File

@@ -106,6 +106,12 @@ public class AppSettings
[JsonPropertyName("reminder")] [JsonPropertyName("reminder")]
public ReminderSettings Reminder { get; set; } = new(); public ReminderSettings Reminder { get; set; } = new();
/// <summary>
/// 항목별 글로벌 전용 핫키 목록. 어떤 앱이 포커스를 가져도 해당 핫키로 즉시 실행됩니다.
/// </summary>
[JsonPropertyName("customHotkeys")]
public List<HotkeyAssignment> CustomHotkeys { get; set; } = new();
[JsonPropertyName("llm")] [JsonPropertyName("llm")]
public LlmSettings Llm { get; set; } = new(); public LlmSettings Llm { get; set; } = new();
} }

View File

@@ -342,6 +342,15 @@ public partial class SettingsViewModel
})) }))
.ToList(); .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 s.Snippets = Snippets.Select(sn => new SnippetEntry
{ {
@@ -416,6 +425,50 @@ public partial class SettingsViewModel
public void RemoveBatchCommand(BatchCommandModel cmd) => BatchCommands.Remove(cmd); 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() public bool AddSnippet()
{ {

View File

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

View File

@@ -268,6 +268,10 @@ public partial class SettingsViewModel : INotifyPropertyChanged
foreach (var ext in s.IndexExtensions) foreach (var ext in s.IndexExtensions)
IndexExtensions.Add(ext); 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) foreach (var sn in s.Snippets)
Snippets.Add(new SnippetRowModel { Key = sn.Key, Name = sn.Name, Content = sn.Content }); 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)); => 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 public class SnippetRowModel : INotifyPropertyChanged

View File

@@ -2791,6 +2791,190 @@
</Grid> </Grid>
</TabItem> </TabItem>
<!-- ══════════════════════════════════════════════════════════════
전용 핫키 탭 — 어떤 앱에서든 항목별 글로벌 단축키 즉시 실행
══════════════════════════════════════════════════════════════ -->
<TabItem Header="전용 핫키" Tag="&#xE7C3;" Style="{StaticResource SideNavItem}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0">
<TextBlock Text="항목별 전용 핫키" Style="{StaticResource SectionHeader}"/>
<!-- 안내 배너 -->
<Border Background="White" CornerRadius="10" Padding="14,11" Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="36" Height="36" CornerRadius="9"
Background="#4B5EFC" Margin="0,0,12,0" VerticalAlignment="Center">
<TextBlock Text="&#xE7C3;" FontFamily="Segoe MDL2 Assets" FontSize="17"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="1" FontSize="12" Foreground="#444466"
TextWrapping="Wrap" VerticalAlignment="Center">
어떤 앱이 포커스를 가져도 등록된 <Run FontWeight="SemiBold" Foreground="#4B5EFC">전용 핫키</Run>로 파일·폴더·앱·URL을 즉시 실행합니다.
지원 형식: <Run FontWeight="SemiBold">Ctrl+Alt+1</Run>, <Run FontWeight="SemiBold">Ctrl+Shift+F2</Run> 등
</TextBlock>
</Grid>
</Border>
<!-- 입력 폼 -->
<Border Background="White" CornerRadius="10" Padding="16,14" Margin="0,0,0,10"
BorderBrush="#E0E0EC" BorderThickness="1.5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="8"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 행 1: 핫키 + 표시 이름 + 타입 -->
<Border Grid.Row="0" Grid.Column="0" CornerRadius="6" Padding="8,6"
Background="#F5F5FA" BorderBrush="#D0D0E0" BorderThickness="1"
Cursor="IBeam" x:Name="HotkeyRecordBorder"
MouseLeftButtonUp="HotkeyRecord_Click"
ToolTip="클릭 후 키를 누르면 자동 입력됩니다">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="HotkeyRecordText" Grid.Column="0"
Text="{Binding NewHotkeyStr, FallbackValue='핫키 클릭 후 입력'}"
FontSize="13" FontWeight="SemiBold" Foreground="#4B5EFC"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="&#xE8AB;" FontFamily="Segoe MDL2 Assets"
FontSize="12" Foreground="#8888AA" Margin="6,0,0,0"
VerticalAlignment="Center"/>
</Grid>
</Border>
<TextBox Grid.Row="0" Grid.Column="2"
Style="{StaticResource ModernTextBox}"
Text="{Binding NewHotkeyLabel, UpdateSourceTrigger=PropertyChanged}"
Tag="표시 이름 (예: 내 문서 폴더)"
ToolTip="AX Commander 결과에 표시될 이름 (비워두면 파일명 자동 사용)"/>
<!-- 타입 드롭다운 -->
<ComboBox Grid.Row="0" Grid.Column="4"
SelectedValue="{Binding NewHotkeyType}"
SelectedValuePath="Tag"
Style="{StaticResource ModernComboBox}">
<ComboBoxItem Tag="app">앱 (exe)</ComboBoxItem>
<ComboBoxItem Tag="folder">폴더</ComboBoxItem>
<ComboBoxItem Tag="url">URL</ComboBoxItem>
<ComboBoxItem Tag="command">명령어</ComboBoxItem>
</ComboBox>
<!-- 행 2: 대상 경로 + 찾아보기 + 추가 -->
<Grid Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Style="{StaticResource ModernTextBox}"
Text="{Binding NewHotkeyTarget, UpdateSourceTrigger=PropertyChanged}"
Tag="대상 경로 또는 URL"
ToolTip="앱 실행 파일 경로, 폴더 경로, 또는 URL"/>
<Button Grid.Column="2" Style="{StaticResource IconButton}"
Click="BrowseHotkeyTarget_Click" ToolTip="파일/폴더 선택">
<TextBlock Text="&#xE8DA;" FontFamily="Segoe MDL2 Assets"
FontSize="13" Foreground="#555566"/>
</Button>
<Button Grid.Column="4" Style="{StaticResource PrimaryButton}"
Click="AddHotkey_Click" ToolTip="전용 핫키 추가">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE710;" FontFamily="Segoe MDL2 Assets"
FontSize="12" Margin="0,0,5,0"/>
<TextBlock Text="추가"/>
</StackPanel>
</Button>
</Grid>
</Grid>
</Border>
</StackPanel>
<!-- 핫키 목록 -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="HotkeyList"
ItemsSource="{Binding CustomHotkeys}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="White" CornerRadius="8" Padding="12,10"
Margin="0,0,0,6" BorderBrush="#E8E8F0" BorderThickness="1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 타입 아이콘 -->
<Border Grid.Column="0" Width="32" Height="32" CornerRadius="8"
Background="#EEF0FF">
<TextBlock Text="{Binding TypeSymbol}"
FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#4B5EFC"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<!-- 핫키 배지 -->
<Border Grid.Column="2" CornerRadius="5" Padding="7,3"
Background="#F3F0FF">
<TextBlock Text="{Binding Hotkey}"
FontSize="12" FontWeight="Bold"
Foreground="#4B5EFC"
FontFamily="Consolas"/>
</Border>
<!-- 이름 + 경로 -->
<StackPanel Grid.Column="4" VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" FontSize="13"
FontWeight="SemiBold" Foreground="#222244"/>
<TextBlock Text="{Binding Target}" FontSize="11"
Foreground="#8888AA"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<!-- 삭제 버튼 -->
<Border Grid.Column="5" Width="28" Height="28" CornerRadius="6"
Background="Transparent" Cursor="Hand"
MouseLeftButtonUp="RemoveHotkey_Click"
Tag="{Binding}"
ToolTip="핫키 제거">
<TextBlock Text="&#xE74D;" FontFamily="Segoe MDL2 Assets"
FontSize="13" Foreground="#CC3333"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</TabItem>
<!-- ══════════════════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════════════════
기능 탭 — 단축키로 활성/비활성화 가능한 기능들의 ON/OFF 설정 기능 탭 — 단축키로 활성/비활성화 가능한 기능들의 ON/OFF 설정
══════════════════════════════════════════════════════════════ --> ══════════════════════════════════════════════════════════════ -->

View File

@@ -132,6 +132,95 @@ public partial class SettingsWindow : Window
_vm.RemoveShortcut(shortcut); _vm.RemoveShortcut(shortcut);
} }
// ─── 전용 핫키 이벤트 핸들러 ───────────────────────────────────────────────
/// <summary>핫키 입력 영역 클릭 → PreviewKeyDown 캡처 모드 활성화</summary>
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<string>();
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) private void AddBatchCommand_Click(object sender, RoutedEventArgs e)
{ {
if (!_vm.AddBatchCommand()) if (!_vm.AddBatchCommand())