Cliply의 빠른 검색 팝업은 이렇게 동작해야 한다.

  1. 어떤 프로그램을 쓰고 있든 Ctrl+Shift+Space를 누르면 뜬다
  2. 마우스가 있는 모니터에, 바로 입력할 수 있는 상태로 뜬다
  3. Enter를 누르면 복사하고 사라진다
  4. 사라지면 원래 쓰던 창으로 포커스가 돌아가서 바로 Ctrl+V 할 수 있다

설계 문서에는 "단축키 → 창 표시 100ms 이내"라는 목표도 적어 두었다. 이 글은 이 네 줄과 숫자 하나를 맞추는 이야기다.

빠른 검색 팝업 (다크 테마)

전역 단축키: RegisterHotKey와 메시지 전용 창

WPF의 KeyBinding은 내 창에 포커스가 있을 때만 동작한다. 다른 프로그램 위에서 받으려면 Win32 RegisterHotKey를 써야 한다. 이 API는 단축키가 눌리면 지정한 창 핸들로 WM_HOTKEY 메시지를 보낸다.

문제는 Cliply에 메인 창이 없다는 것이다. 트레이에만 상주한다. 그래서 화면에 보이지 않는 메시지 전용 창(HWND_MESSAGE를 부모로 둔 창)을 HwndSource로 만들어 메시지만 받는다.

_source = new HwndSource(new HwndSourceParameters("CliplyHotkeySink")
{
    ParentWindow = NativeMethods.HWND_MESSAGE,
    WindowStyle = 0,
});
_source.AddHook(WndProc);
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
    if (msg == NativeMethods.WM_HOTKEY && wParam.ToInt32() == HotkeyId)
    {
        handled = true;
        Pressed?.Invoke();
    }

    return IntPtr.Zero;
}

등록할 때는 MOD_NOREPEAT를 같이 준다. 단축키를 꾹 누르고 있을 때 키 반복으로 팝업이 열렸다 닫혔다 하는 것을 막는다.

다른 프로그램이 이미 쓰는 단축키

RegisterHotKey는 다른 프로그램이 같은 조합을 잡고 있으면 실패한다. 설정에서 단축키를 바꾸다가 실패하면, 기존 단축키까지 풀린 채로 남으면 안 된다. 그래서 등록 순서를 이렇게 정했다.

var previous = Current;
UnregisterCore();

if (TryRegister(gesture))
{
    Current = gesture;
    return true;
}

// 실패하면 이전 단축키를 다시 등록
if (previous is { } old && TryRegister(old))
{
    Current = old;
}

return false;

설정 창은 실패를 받으면 "이미 다른 프로그램이 사용 중인 단축키입니다"를 보여 주고 이전 값을 유지한다. 앱 시작 때 실패하면 트레이 알림으로 알려 준다.

설정 창에서 새 단축키를 녹화하는 동안에는 기존 단축키를 잠시 해제(Suspend)한다. 그렇지 않으면 지금 쓰는 조합을 다시 누를 때 팝업이 떠 버린다. 다른 프로그램이 RegisterHotKey로 잡은 조합은 키 입력 자체가 내 창에 오지 않아서 녹화되지 않는데, 이건 정상 동작이다.

설정 창: 단축키 녹화 입력과 언어·테마

포커스를 원래 창에 돌려주기

팝업이 뜨기 직전, 지금 포그라운드인 창 핸들을 저장해 둔다. 복사하고 닫을 때 그 창이 아직 살아 있으면 SetForegroundWindow로 되돌린다.

public async Task ShowPopupAsync()
{
    var foreground = ForegroundWindowHelper.GetForeground();
    if (foreground != new WindowInteropHelper(this).Handle)
    {
        _previousForeground = foreground;
    }

    await _viewModel.PrepareAsync();
    PositionOnCursorMonitor();

    Show();
    Activate();
    ForegroundWindowHelper.BringToFront(this);
    QueryBox.Focus();
    QueryBox.SelectAll();
}

팝업이 이미 떠 있는 상태에서 단축키를 다시 누르면 토글로 닫히는데, 이때 포그라운드는 팝업 자신이다. 그래서 자기 핸들일 때는 저장값을 덮어쓰지 않는다.

닫는 방법은 두 가지로 나눴다.

닫히는 상황 포커스 복원
Enter로 복사, Esc, 단축키 다시 누름 원래 창으로 되돌림
팝업 밖을 클릭 (Deactivated) 하지 않음 — 사용자가 이미 다른 창을 골랐다

팝업 밖을 눌렀는데 포커스를 원래 창으로 되돌리면, 사용자가 방금 클릭한 창을 빼앗게 된다. 이 구분이 없으면 쓰는 내내 거슬린다.

또 하나, 팝업은 닫지 않고 숨긴다(Hide). 창을 매번 새로 만들면 다음 호출이 느려진다. 한 번 만든 창을 Show/Hide로 재사용한다.

마우스가 있는 모니터에, DPI를 맞춰서

모니터가 두세 대면 "주 모니터 가운데"에 뜨는 팝업은 고개를 돌리게 만든다. 그래서 마우스 커서가 있는 모니터의 가운데, 위에서 25% 지점에 띄운다. GetCursorPos → MonitorFromPoint → GetMonitorInfo로 그 모니터의 영역과 작업 영역(작업 표시줄 제외)을 얻는다.

여기서 단위가 문제다. Win32는 물리 픽셀을 주고, WPF의 Left/Top은 장치 독립 단위(DIP, 1/96인치)다. 배율이 150%인 모니터에서 픽셀 값을 그대로 넣으면 엉뚱한 곳에 뜬다. 창의 DPI 배율로 나눠 DIP로 바꾼다.

var scale = VisualTreeHelper.GetDpi(this).DpiScaleX;
var (monitor, work) = ForegroundWindowHelper.GetCursorMonitor(scale);

var left = monitor.Left + (monitor.Width - Width) / 2;
var top = monitor.Top + monitor.Height * 0.25 - ShadowMargin;

Left = Math.Clamp(left, work.Left, Math.Max(work.Left, work.Right - Width));
Top = Math.Clamp(top, work.Top, Math.Max(work.Top, work.Bottom - 200));

ShadowMargin은 그림자 때문에 생긴 바깥 여백이다. 팝업은 WindowStyle="None", AllowsTransparency="True"인 투명 창 안에 둥근 Border와 DropShadowEffect를 넣어 만들었는데, 그림자가 그려질 자리만큼 여백이 필요하다. 위치를 계산할 때 그만큼 빼야 눈에 보이는 창이 정확히 25% 지점에 온다.

첫 호출 82ms: 화면 밖에서 미리 한 번 그리기

모든 구현을 마치고 측정해 보니 두 번째 호출부터는 빨랐지만 첫 호출이 눈에 띄게 늦었다. WPF는 창을 처음 보여 줄 때 창 핸들을 만들고, 레이아웃·템플릿을 처음 적용하고, JIT 컴파일을 한다. 검색 쪽도 EF Core가 첫 쿼리를 컴파일한다.

그래서 앱 시작 때 이 일을 미리 해 둔다.

public async Task PrewarmAsync()
{
    new WindowInteropHelper(this).EnsureHandle();
    await _viewModel.PrepareAsync();

    _prewarming = true;
    ShowActivated = false;
    Left = -32000;
    Top = -32000;
    try
    {
        Show();
        await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ContextIdle);
        Hide();
    }
    finally
    {
        ShowActivated = true;
        _prewarming = false;
    }
}

창을 화면 밖(-32000)에 활성화 없이 띄우고, 디스패처가 렌더링까지 끝내길(ContextIdle) 기다렸다가 숨긴다. 이 동안 Deactivated 이벤트가 숨김 처리를 하지 않게 _prewarming 플래그로 막는다.

결과는 표시까지 첫 호출 82ms, 이후 30~50ms다. 목표 100ms 안에 들어왔다.

검색은 80ms 디바운스, 결과는 30개

키를 칠 때마다 검색하면 docker를 치는 동안 쿼리가 여섯 번 나간다. 마지막 입력 후 80ms 동안 추가 입력이 없을 때만 검색한다. 이전 검색은 CancellationToken으로 취소한다. 사람이 체감하기에는 바로 나오는 것과 차이가 없다.

결과는 최대 30개, 각 항목에 제목·본문 첫 줄·언어 배지·태그를 보여 주고, 선택한 항목의 본문을 아래에 최대 8줄 미리 보여 준다. 키 조작은 이렇다.

키 동작
↑ / ↓ 선택 이동 (끝에서 순환)
Enter 복사 후 닫기
Esc 검색어가 있으면 지우기, 없으면 닫기
Ctrl+E 관리 창에서 편집
Ctrl+N 관리 창에서 새 스니펫 (검색어를 제목으로)
Ctrl+P 고정 / 해제

↑/↓도 검색창의 PreviewKeyDown에서 처리한다. 입력 포커스는 항상 검색창에 두고 목록 선택만 바꾸는 방식이다. 방향키로 고르다가 바로 글자를 더 쳐도 검색어에 들어간다.

클립보드는 잠겨 있을 수 있다

Windows 클립보드는 한 번에 한 프로그램만 열 수 있다. 클립보드 관리 프로그램이나 원격 데스크톱이 잠깐 잡고 있으면 Clipboard.SetDataObject가 COMException을 던진다. 50ms 간격으로 최대 5번 다시 시도하고, 그래도 안 되면 "클립보드를 다른 프로그램이 사용 중"이라고 알려 준다.

for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
    try
    {
        Clipboard.SetDataObject(text, copy: true);
        return true;
    }
    catch (COMException ex) when (attempt < MaxAttempts)
    {
        await Task.Delay(RetryDelay, cancellationToken);
    }
    // ...
}

복사에 성공하면 팝업 아래에 "복사됨"을 0.6초 보여 준 뒤 닫는다. 트레이 풍선 알림은 쓰지 않았다. 눈이 이미 팝업에 있으니 거기서 확인하는 게 자연스럽다. 이 표시는 설정에서 끌 수 있다.

마우스로 고르면 엉뚱한 항목이 복사됐다

배포 전에 다시 써 보다가 결함을 하나 찾았다. 키보드로는 잘 되는데, 마우스로 항목을 클릭해도 선택이 바뀌지 않고, 더블클릭하면 원래 선택돼 있던 항목이 복사됐다.

원인은 위에서 말한 "포커스는 항상 검색창" 설계였다. 결과 항목이 포커스를 가져가지 않도록 Focusable=False로 해 두었는데, ListBox는 포커스를 받을 수 없는 항목의 클릭을 선택으로 처리하지 않는다. 더블클릭은 결국 기존 선택 항목에 대한 복사 명령이 됐다.

클릭을 직접 처리하는 것으로 고쳤다.

private void ResultList_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    if (ItemsControl.ContainerFromElement(ResultList, (DependencyObject)e.OriginalSource)
        is not ListBoxItem { DataContext: SnippetListItemViewModel item })
    {
        return;
    }

    e.Handled = true;
    _viewModel.SelectedResult = item;
    QueryBox.Focus();

    if (e.ClickCount == 2)
    {
        _viewModel.CopyCommand.Execute(null);
    }
}

클릭 = 그 항목 선택(포커스는 검색창 유지), 더블클릭 = 그 항목 복사. 키보드 중심으로 설계하면 마우스 경로를 빠뜨리기 쉽다는 교훈을 남겼다.

정리

  • 메인 창 없는 앱의 전역 단축키는 메시지 전용 HwndSource + RegisterHotKey로 받는다. MOD_NOREPEAT를 같이 준다.
  • 단축키 등록이 실패하면 이전 단축키를 다시 등록해 "아무 키도 없는" 상태를 만들지 않는다.
  • 팝업 직전 포그라운드 창을 저장해 두고, 사용자가 다른 창을 클릭해서 닫힌 경우에는 되돌리지 않는다.
  • 모니터 좌표는 DPI 배율로 나눠 DIP로 바꾼다.
  • 첫 호출 지연은 화면 밖 미리 렌더링으로 줄인다 (82ms, 이후 30~50ms).
  • 포커스를 받지 않는 ListBox 항목은 클릭 선택도 직접 처리해야 한다.

다음 글에서는 복사할 때 {{컨테이너}} 같은 자리를 채워 주는 변수 치환을 다룬다.


Cliply 개발기

  1. 자주 쓰는 명령어를 단축키 한 번으로 — 「Cliply」 개발기
  2. SQLite FTS5 trigram으로 한글·부분 문자열 검색하기
  3. 어느 창에서든 단축키로 뜨는 WPF 검색 팝업 (이 글)
  4. {{이름:기본값}} 변수 치환 파서와 입력 창 (10월 7일 공개)
  5. 동기화를 대비한 스키마 — GUID, UpdatedAt, 소프트 삭제와 JSON 가져오기 (10월 7일 공개)
  6. 트레이 상주 앱 마무리 — 단일 인스턴스, 한/영 전환, 70MB 단일 exe (10월 7일 공개)