Cliply의 빠른 검색 팝업은 이렇게 동작해야 한다.
- 어떤 프로그램을 쓰고 있든
Ctrl+Shift+Space를 누르면 뜬다 - 마우스가 있는 모니터에, 바로 입력할 수 있는 상태로 뜬다
Enter를 누르면 복사하고 사라진다- 사라지면 원래 쓰던 창으로 포커스가 돌아가서 바로
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 개발기
- 자주 쓰는 명령어를 단축키 한 번으로 — 「Cliply」 개발기
- SQLite FTS5 trigram으로 한글·부분 문자열 검색하기
- 어느 창에서든 단축키로 뜨는 WPF 검색 팝업 (이 글)
{{이름:기본값}}변수 치환 파서와 입력 창 (10월 7일 공개)- 동기화를 대비한 스키마 — GUID, UpdatedAt, 소프트 삭제와 JSON 가져오기 (10월 7일 공개)
- 트레이 상주 앱 마무리 — 단일 인스턴스, 한/영 전환, 70MB 단일 exe (10월 7일 공개)