콘텐츠로 건너뛰기
Codex

투명·클릭 통과 창을 직접 만들어 보기

투명·클릭 통과 창을 직접 만들어 보기

TECH NOTE · DESKTOP AI PET

화면 위에 항상 떠 있으면서 빈 곳의 클릭은 뒤로 통과시키는 창을 만드는 법입니다. UI를 알파값보다 먼저 봐야 하는 이유와 pointer-events가 창 전체를 먹는 함정을, 돌아가는 최소 예제와 함께 정리했습니다.

창은 사각형인데 캐릭터는 사각형이 아니다

지난 편에서 앱을 받아 띄웠다. 이번 편부터 그 앱을 만들면서 쓴 코드를 하나씩 공개한다.

첫 순서는 창이다. 데스크톱 펫은 화면 위에 얹혀 있으면서 마우스를 가로채지 않아야 한다. 캐릭터를 누르면 캐릭터가 반응하고, 캐릭터 옆 빈 공간을 누르면 뒤에 있는 브라우저가 반응해야 한다.

앞서 설계 판단을 다룬 글에서 왜 이렇게 만들었는지 적었다. 이번 편은 그것을 돌아가는 최소 예제로 옮긴다.

https://github.com/bluecafe/pet-reference/tree/main/overlay-window

받아서 cargo run을 하면 파란 원이 화면 위에 뜬다. 원을 누르면 잡히고, 원 밖을 누르면 뒤에 있는 창이 받는다. 왼쪽 위 배지가 커서 아래에 무엇이 있는지 실시간으로 보여준다.

설정으로 끝나는 부분과 아닌 부분

투명하고, 테두리 없고, 항상 위에 뜨는 것까지는 설정 파일 몇 줄이다. 코드가 아니다.

{
  "transparent": true,
  "decorations": false,
  "alwaysOnTop": true,
  "skipTaskbar": true,
  "shadow": false,
  "acceptFirstMouse": true
}

shadow를 빼먹기 쉽다. 켜져 있으면 투명한 빈 공간에도 네모난 그림자가 남는다. 투명 창을 만들어 놓고 사각형 윤곽이 그대로 보이는 상태가 된다.

macOS에서는 옵션이 하나 더 필요하다. macOSPrivateApi를 켜야 투명 창이 된다. 이 옵션을 쓴 앱은 App Store에 올릴 수 없다. 그쪽으로 낼 계획이면 여기서 다른 길을 찾아야 한다.

클릭 통과만 구현이다. 그래서 예제의 Rust 코드는 열 줄도 안 된다. 창을 띄우는 일에는 넣을 게 없고, 판정은 전부 프론트엔드에 있다.

켜고 끄는 문제다

창 전체를 마우스 이벤트에서 빼는 API가 있다.

setIgnoreCursorEvents(true)

그런데 이걸 켜두면 캐릭터를 눌러도 반응하지 않는다. 창 전체가 통과 대상이 되기 때문이다. 꺼두면 빈 공간이 클릭을 먹는다.

둘 중 하나를 고르는 문제가 아니라, 커서가 지금 어디 있는지에 따라 켜고 끄는 문제다. 그래서 커서가 움직일 때마다 그 지점을 판정한다.

판정은 세 갈래다

예제에서 이 부분만 따로 떼어 ui/hit-test.js 한 파일로 두었다. DOM도 창 API도 참조하지 않는 순수 함수라 창을 띄우지 않고 검증할 수 있다.

node --test hit-test.test.js

판정 함수가 이렇게 생겼다.

export function resolveOwner(elementStyles, alpha) {
  if (elementStyles.some(acceptsMouse)) {
    return Owner.UI;
  }
  if (alpha === null) {
    return Owner.CHARACTER;
  }
  return alpha > 0 ? Owner.CHARACTER : Owner.PASS_THROUGH;
}

셋 중 하나가 나온다.

커서 아래에 클릭을 받는 것
마우스를 받는 UI가 있다 UI
UI는 없고 캔버스 알파값이 0보다 크다 캐릭터
둘 다 아니다 뒤에 있는 창 (통과)

순서가 중요하다. UI를 알파값보다 먼저 본다.

말풍선이나 설정 버튼은 캔버스 위에 얹은 HTML이라 캔버스 픽셀이 아니다. 알파값을 읽으면 0이 나온다. 알파값만 보면 버튼이 보이는데 클릭이 뒤로 빠져나간다.

`pointer-events: none`이 창 전체를 먹는다

UI를 확인할 때 그냥 “그 자리에 요소가 있나”를 보면 안 된다. 걸러야 할 것이 세 가지다.

export function acceptsMouse(style) {
  if (!style) return false;
  if (style.display === "none") return false;
  if (style.visibility === "hidden") return false;
  if (style.pointerEvents === "none") return false;
  return true;
}

마지막이 특히 위험하다. 장식용으로 얹어둔 레이어가 화면 전체를 덮고 있는 경우가 흔한데, 그것을 안 거르면 창 전체가 항상 클릭을 먹는다. 클릭 통과가 아예 동작하지 않는 상태가 되고, 원인을 찾기도 어렵다.

예제에 그런 요소를 일부러 넣어 뒀다. 점선 테두리가 화면 전체를 덮고 있지만 클릭은 통과한다. 테스트가 이 경우를 확인한다.

판정에 실패하면 통과시키지 않는다

알파값을 읽는 함수는 캔버스 상태에 따라 실패할 수 있다. 그때 무엇을 돌려줄지가 갈림길이다.

실패를 통과로 처리하면 안 된다. 그러면 캐릭터를 눌렀는데 뒤에 있는 창이 반응한다. 사용자는 자기가 누른 것이 사라졌다고 느낀다.

모를 때는 받아 두는 쪽이 낫다. 클릭이 한 번 안 먹는 것보다 엉뚱한 창이 반응하는 게 더 이상하다. 예제는 실패를 “캐릭터가 받는다”로 처리한다.

좌표는 반드시 환산한다

화면 좌표를 그대로 캔버스 좌표로 쓰면 안 된다. 창 크기와 캔버스 해상도가 다를 수 있다.

export function toCanvasPixel(clientX, clientY, rect, canvasWidth, canvasHeight) {
  return {
    x: Math.floor(((clientX - rect.left) / rect.width) * canvasWidth),
    y: Math.floor(((clientY - rect.top) / rect.height) * canvasHeight),
  };
}

rect는 창에 실제로 그려진 크기고 canvasWidth는 캔버스의 픽셀 해상도다. 둘의 비를 곱해야 같은 지점을 가리킨다.

이걸 빼먹으면 판정 위치가 캐릭터와 어긋난다. 증상이 특이한데, 고해상도 화면에서 캐릭터의 오른쪽 아래를 눌러야 반응한다. 보이는 곳과 판정하는 곳이 배율만큼 벌어져 있기 때문이다.

예제 테스트에 640px 창과 320px 캔버스 조합을 넣어 뒀다. 환산하면 화면 좌표의 절반이 캔버스 좌표가 되고, 환산을 빼먹으면 캔버스 밖을 가리킨다.

값이 바뀔 때만 창에 알린다

마지막으로 성능이다. 커서가 움직일 때마다 창 설정을 바꾸면 낭비다. 마우스 이동은 초당 수십 번 발생하고, 그때마다 프로세스 경계를 넘는 호출이 나간다.

let ignoring = null;

async function update(clientX, clientY) {
  const owner = resolveOwner(stylesAt(clientX, clientY), alphaAt(clientX, clientY));
  const ignore = shouldIgnoreCursor(owner);

  if (ignore !== ignoring) {
    ignoring = ignore;
    await appWindow.setIgnoreCursorEvents(ignore);
  }
}

ignoringnull로 시작하는 것이 작지만 필요하다. false로 두면 첫 판정이 “통과 안 함”일 때 호출이 생략되는데, 창의 초기 상태가 그 값이라는 보장이 없다.

판정 결과가 직전과 다를 때만 호출한다. 캐릭터 위에서 커서를 계속 움직여도 호출은 처음 한 번이다.

알파값을 읽는 것 자체는 커서가 움직였을 때 픽셀 하나다. 프레임마다 전체를 훑는 게 아니라서 부담이 크지 않다.

정리

투명하고 클릭이 통과하는 창은 설정과 구현이 반씩이다.

  • 투명·테두리 없음·항상 위·그림자 끄기는 설정이다
  • 클릭 통과는 커서 위치에 따라 켜고 끄는 구현이다
  • UI를 알파값보다 먼저 본다. 순서를 바꾸면 버튼이 안 눌린다
  • pointer-events: none을 거르지 않으면 창 전체가 클릭을 먹는다
  • 판정에 실패하면 통과시키지 말고 받아 둔다
  • 좌표는 배율로 환산한다
  • 창 설정은 값이 바뀔 때만 바꾼다

돌아가는 코드는 pet-reference/overlay-window에 있다.

이 예제는 앞으로 공개할 것들과 달리 Tauri가 필요하다. 창이 실제로 떠야 확인되는 내용이라 어쩔 수 없었다. 대신 판정 로직은 따로 떼어 뒀으니, 창을 띄우기 전에 규칙만 먼저 봐도 된다.

완성된 앱은 다운로드 페이지에서 받을 수 있다. 캐릭터 옆 빈 공간을 눌러 보면 이 판정이 무엇을 하고 있는지 바로 안다.

다음 편은 그 창 안에 무엇을 그릴지다. 스프라이트 시트의 프레임 규격을 어떻게 정했는지 다룬다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다