Files
AX-Copilot-Codex/src/AxCopilot/Core/SnippetExpander.cs
lacvet e4e3e49419
Some checks failed
Release Gate / gate (push) Has been cancelled
글로벌 키보드 훅 타이핑 버벅임 완화
다른 앱 타이핑 시에도 AX Copilot가 과하게 개입하던 글로벌 키보드 훅의 핫패스를 줄였다.

InputListener는 모든 키마다 파일 대화상자 억제 창 검사를 하지 않고 실제 핫키·캡처·키필터 후보 키에서만 검사하도록 최적화했다.

SnippetExpander는 추적 중이 아닐 때 ';' 시작 키 외에는 즉시 반환하게 바꿔 일반 타이핑 중 반복적인 modifier 상태 확인과 버퍼 처리를 제거했다.

README와 DEVELOPMENT 문서를 2026-04-06 18:34 (KST) 기준으로 갱신했고, Release 빌드 검증에서 경고 0 / 오류 0을 확인했다.
2026-04-06 18:09:21 +09:00

263 lines
9.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.InteropServices;
using System.Text;
using System.Windows;
using AxCopilot.Services;
namespace AxCopilot.Core;
/// <summary>
/// 모든 앱에서 ';키워드 + Space/Enter' 패턴을 감지해 스니펫을 자동 확장합니다.
/// InputListener.KeyFilter에 <see cref="HandleKey"/> 을 등록하여 사용합니다.
/// </summary>
public class SnippetExpander
{
private readonly SettingsService _settings;
private readonly StringBuilder _buffer = new();
private bool _tracking;
// VK 상수
private const ushort VK_BACK = 0x08;
private const int VK_ESCAPE = 0x1B;
private const int VK_SPACE = 0x20;
private const int VK_RETURN = 0x0D;
private const int VK_OEM_1 = 0xBA; // ; (US QWERTY)
private const int VK_SHIFT = 0x10;
private const int VK_CONTROL = 0x11;
private const int VK_MENU = 0x12;
private const ushort VK_CTRL_US = 0x11;
// 방향키 / 기능키 — 이 키가 오면 버퍼 초기화
private static readonly HashSet<int> ClearKeys = new()
{
0x21, 0x22, 0x23, 0x24, // PgUp, PgDn, End, Home
0x25, 0x26, 0x27, 0x28, // ←↑→↓
0x2E, // Delete
};
public SnippetExpander(SettingsService settings)
{
_settings = settings;
}
/// <summary>
/// InputListener.KeyFilter에 등록할 메서드.
/// true → 해당 키 이벤트 소비(차단), false → 통과.
/// 훅 스레드에서 호출되므로 신속히 처리해야 합니다.
/// </summary>
public bool HandleKey(int vkCode)
{
// 자동 확장 비활성화 시 즉시 통과
if (!_settings.Settings.Launcher.SnippetAutoExpand) return false;
// 추적 중이 아닐 때는 ';' 시작 키만 검사해서 훅 경로 부담을 최소화합니다.
if (!_tracking)
{
if (vkCode != VK_OEM_1)
return false;
if ((GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0)
return false;
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0)
return false;
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0)
return false;
_tracking = true;
_buffer.Clear();
_buffer.Append(';');
return false;
}
// Ctrl/Alt 조합은 무시 (단축키와 충돌 방지)
if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0) { _tracking = false; _buffer.Clear(); return false; }
// ─── 영문자/숫자 — 버퍼에 추가 ─────────────────────────────────────
if ((vkCode >= 0x41 && vkCode <= 0x5A) || // A-Z
(vkCode >= 0x30 && vkCode <= 0x39) || // 0-9
(vkCode >= 0x60 && vkCode <= 0x69) || // Numpad 0-9
vkCode == 0xBD) // -
{
bool shifted = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
char c = VkToChar(vkCode, shifted);
if (c != '\0') _buffer.Append(char.ToLowerInvariant(c));
return false;
}
// ─── BackSpace — 버퍼에서 한 글자 제거 ──────────────────────────────
if (vkCode == VK_BACK)
{
if (_buffer.Length > 1)
_buffer.Remove(_buffer.Length - 1, 1);
else
{
_tracking = false;
_buffer.Clear();
}
return false;
}
// ─── Space / Enter — 스니펫 확장 시도 ───────────────────────────────
if (vkCode == VK_SPACE || vkCode == VK_RETURN)
{
if (_buffer.Length > 1) // ';' 이후 한 글자 이상
{
var keyword = _buffer.ToString(1, _buffer.Length - 1);
_tracking = false;
_buffer.Clear();
var snippet = _settings.Settings.Snippets.FirstOrDefault(
s => s.Key.Equals(keyword, StringComparison.OrdinalIgnoreCase));
if (snippet != null)
{
var expanded = ExpandVariables(snippet.Content);
var deleteCount = keyword.Length + 1; // ';' + keyword
// 트리거 키(Space/Enter) 소비 후 UI 스레드에서 확장 처리
Application.Current.Dispatcher.BeginInvoke(() =>
PasteExpansion(expanded, deleteCount));
return true; // 트리거 키 소비
}
}
_tracking = false;
_buffer.Clear();
return false;
}
// ─── Escape / 방향키 / 기능키 — 추적 중단 ───────────────────────────
if (vkCode == VK_ESCAPE || ClearKeys.Contains(vkCode) || vkCode >= 0x70)
{
_tracking = false;
_buffer.Clear();
}
return false;
}
// ─── 확장 실행 (UI 스레드) ───────────────────────────────────────────────
private static void PasteExpansion(string text, int deleteCount)
{
try
{
// 1. Backspace × deleteCount
var inputs = new INPUT[deleteCount * 2];
for (int i = 0; i < deleteCount; i++)
{
inputs[i * 2] = MakeKeyInput(VK_BACK, false);
inputs[i * 2 + 1] = MakeKeyInput(VK_BACK, true);
}
SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
// 2. 클립보드 → Ctrl+V
Clipboard.SetText(text);
var paste = new[]
{
MakeKeyInput(VK_CTRL_US, false),
MakeKeyInput(0x56, false), // V
MakeKeyInput(0x56, true),
MakeKeyInput(VK_CTRL_US, true),
};
SendInput((uint)paste.Length, paste, Marshal.SizeOf<INPUT>());
LogService.Info($"스니펫 확장 완료: {deleteCount}자 삭제 후 붙여넣기");
}
catch (Exception ex)
{
LogService.Warn($"스니펫 확장 실패: {ex.Message}");
}
}
private static INPUT MakeKeyInput(ushort vk, bool keyUp)
{
var input = new INPUT { type = 1 };
input.u.ki.wVk = vk;
input.u.ki.dwFlags = keyUp ? 0x0002u : 0u; // KEYEVENTF_KEYUP
return input;
}
// ─── 변수 치환 ────────────────────────────────────────────────────────────
private static string ExpandVariables(string content)
{
var now = DateTime.Now;
return content
.Replace("{date}", now.ToString("yyyy-MM-dd"))
.Replace("{time}", now.ToString("HH:mm:ss"))
.Replace("{datetime}", now.ToString("yyyy-MM-dd HH:mm:ss"))
.Replace("{year}", now.Year.ToString())
.Replace("{month}", now.Month.ToString("D2"))
.Replace("{day}", now.Day.ToString("D2"));
}
// ─── VK → Char 매핑 (US QWERTY 기준) ────────────────────────────────────
private static char VkToChar(int vk, bool shifted)
{
if (vk >= 0x41 && vk <= 0x5A)
return shifted ? (char)vk : char.ToLowerInvariant((char)vk);
if (vk >= 0x30 && vk <= 0x39)
return shifted ? ")!@#$%^&*("[vk - 0x30] : (char)vk;
if (vk >= 0x60 && vk <= 0x69)
return (char)('0' + (vk - 0x60));
if (vk == 0xBD)
return shifted ? '_' : '-';
return '\0';
}
// ─── P/Invoke ────────────────────────────────────────────────────────────
[DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[StructLayout(LayoutKind.Sequential)]
private struct INPUT
{
public int type;
public InputUnion u;
}
[StructLayout(LayoutKind.Explicit)]
private struct InputUnion
{
[FieldOffset(0)] public MOUSEINPUT mi;
[FieldOffset(0)] public KEYBDINPUT ki;
[FieldOffset(0)] public HARDWAREINPUT hi;
}
[StructLayout(LayoutKind.Sequential)]
private struct KEYBDINPUT
{
public ushort wVk;
public ushort wScan;
public uint dwFlags;
public uint time;
public IntPtr dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
private struct MOUSEINPUT
{
public int dx;
public int dy;
public uint mouseData;
public uint dwFlags;
public uint time;
public IntPtr dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
private struct HARDWAREINPUT
{
public uint uMsg;
public ushort wParamL;
public ushort wParamH;
}
}