콘텐츠로 건너뛰기
Codex

Tauri로 투명·항상 위·클릭 통과 오버레이 만들기

Tauri로 투명·항상 위·클릭 통과 오버레이 만들기

TECH NOTE · DESKTOP AI PET

Tauri로 투명·항상 위 창을 만들면 빈 공간까지 클릭을 먹습니다. 캔버스 알파값으로 캐릭터를 판정해 클릭을 통과시키는 방법을 정리했습니다.

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

Tauri로 데스크톱 펫을 만들면 창 설정 몇 줄로 투명하고 테두리 없고 항상 위에 뜨는 창까지는 금방 간다. 문제는 그다음이다. 창은 여전히 사각형이라, 캐릭터가 없는 투명한 영역에서도 마우스 클릭을 먹는다. 캐릭터 옆 빈 공간을 눌렀는데 뒤에 있는 브라우저가 아니라 펫 창이 반응한다.

여기서는 그 부분을 어떻게 처리했는지 다룬다. 앞의 세 가지(투명·테두리 없음·항상 위)는 설정이고, 클릭 통과는 구현이다.

설정으로 끝나는 부분

창 자체는 tauri.conf.json에서 정한다. 실제로 쓰는 값이다.

{
  "label": "main",
  "width": 320,
  "height": 320,
  "transparent": true,
  "decorations": false,
  "alwaysOnTop": true,
  "skipTaskbar": true,
  "shadow": false,
  "resizable": false,
  "maximizable": false,
  "minimizable": false,
  "acceptFirstMouse": true
}

각각이 하는 일은 이렇다.

옵션 효과
transparent 배경을 비운다. 캐릭터만 떠 있게 하려면 필수다
decorations 제목 표시줄과 창 테두리를 없앤다
alwaysOnTop 다른 창 위에 머문다
skipTaskbar 작업표시줄/독에 항목을 만들지 않는다
shadow 창 그림자를 끈다. 켜두면 투명 영역에도 그림자가 생긴다
acceptFirstMouse 비활성 상태에서 첫 클릭이 바로 먹힌다

shadow는 빼먹기 쉬운데, 이게 켜져 있으면 캐릭터 주변 빈 공간에 네모난 그림자가 남는다. 투명 창을 만들어 놓고 사각형 윤곽이 보이는 상태가 된다.

클릭 통과는 설정이 아니다

여기부터가 실제 구현이다. Tauri에는 창 전체를 마우스 이벤트에서 빼는 API가 있다.

appWindow.setIgnoreCursorEvents(true)

그런데 이걸 켜두면 캐릭터를 클릭해도 반응하지 않는다. 창 전체가 통과 대상이 되기 때문이다. 반대로 꺼두면 빈 공간이 클릭을 먹는다. 둘 중 하나를 고르는 문제가 아니라, 마우스가 지금 캐릭터 위에 있는지에 따라 켜고 끄는 문제다.

그래서 커서가 움직일 때마다 그 지점이 캐릭터인지 판정하고, 결과에 따라 통과 여부를 바꾼다.

캐릭터인지 아닌지는 알파값으로 판정한다

캐릭터는 캔버스에 스프라이트로 그린다. 그러면 어떤 좌표가 캐릭터인지는 그 지점의 알파값을 읽으면 알 수 있다. 투명하면 배경, 불투명하면 캐릭터다.

캔버스 좌표로 환산해서 픽셀 하나를 읽는 방식이다.

const px = Math.floor(((clientX - rect.left) / rect.width) * canvas.width);
const py = Math.floor(((clientY - rect.top) / rect.height) * canvas.height);
return ctx.getImageData(px, py, 1, 1).data[3];

data[3]이 알파다. 이 값이 0이면 그 자리에는 아무것도 없으니 클릭이 뒤로 지나가야 한다.

화면 크기와 캔버스 해상도가 다를 수 있어서 비율로 환산하는 과정이 필요하다. 이걸 빼먹으면 판정 위치가 캐릭터와 어긋난다.

UI 요소는 알파값과 별개로 봐야 한다

알파값만 쓰면 문제가 하나 생긴다. 말풍선이나 설정 버튼처럼 캔버스 위에 얹은 HTML 요소는 캔버스 픽셀이 아니라서 알파값이 0으로 나온다. 버튼이 보이는데 클릭이 뒤로 빠져나가는 상황이 된다.

그래서 그 지점에 마우스를 받는 UI가 있는지 따로 확인한다.

document.elementsFromPoint(clientX, clientY).some((element) => { ... })

여기서 걸러야 하는 것들이 있다. 숨겨진 요소, display: none, visibility: hidden, 그리고 pointer-events: none인 요소다. 마지막 것이 특히 중요하다. 장식용으로 얹어둔 레이어가 화면 전체를 덮고 있으면, 그것 때문에 창 전체가 클릭을 먹게 된다.

정리하면 판정은 두 갈래다.

조건 클릭 통과
그 지점에 마우스를 받는 UI가 있다 통과하지 않음 (UI가 받는다)
UI는 없고 캔버스 알파값이 0보다 크다 통과하지 않음 (캐릭터가 받는다)
둘 다 아니다 통과 (뒤에 있는 창이 받는다)

드래그로 옮길 때 생기는 함정

펫을 드래그해서 옮기려면 네이티브 창 드래그를 쓰게 된다. 여기서 플랫폼별 함정이 하나 있다.

Windows에서는 네이티브 드래그가 시작되면 WebView가 pointerup을 못 받는 경우가 있다. 그러면 마우스를 놓았는데도 코드는 여전히 “잡고 있는 중”이라고 생각한다. 캐릭터가 커서에 붙어서 안 떨어진다.

그래서 드래그를 시작한 뒤에는 브라우저 이벤트를 기다리는 대신 OS 쪽 마우스 버튼 상태를 주기적으로 확인한다. 몇 번 연속으로 “떼어짐”이 나오면 그때 놓은 것으로 처리한다. 그것마저 실패할 때를 대비한 시간 제한도 함께 둔다.

클릭과 드래그를 구분하는 기준도 필요하다. 눌렀다 뗄 때까지 움직인 거리가 몇 픽셀 이하면 클릭으로 본다. 이 값이 너무 작으면 손떨림 때문에 클릭이 드래그로 오인되고, 너무 크면 실제로 옮기려는 동작이 클릭으로 먹혀서 창이 안 움직인다. 데스크톱 UI에서 흔히 쓰는 범위가 3~5픽셀이라 그 근처에서 정했다.

같이 나오는 질문

창 모양을 캐릭터 모양으로 만들 수는 없나

OS 레벨에서 창을 비사각형으로 만드는 방법이 있긴 하지만, 캐릭터가 프레임마다 모양이 바뀌는 경우에는 맞지 않다. 애니메이션이 돌 때마다 창 모양을 다시 계산해야 한다. 알파값으로 판정하는 쪽이 프레임이 바뀌어도 그대로 동작한다.

매 프레임 픽셀을 읽으면 느리지 않나

읽는 건 커서가 움직였을 때 한 픽셀이다. 프레임마다 전체를 훑는 게 아니라서 부담이 크지 않다. 다만 getImageData는 캔버스 상태에 따라 실패할 수 있어서 예외 처리는 해두는 게 좋다.

`pointer-events: none`은 왜 신경 써야 하나

투명한 오버레이 레이어를 화면 전체에 깔아두는 경우가 많은데, 이걸 빠뜨리면 그 레이어가 항상 잡혀서 클릭이 절대 통과하지 않는다. 눈에 보이지 않는 요소라 원인을 찾기 어려운 종류의 문제다.

다음에 볼 건 캐릭터 애니메이션을 어떤 포맷으로 넣을지다. 알파값 판정이 되려면 캔버스에 그려야 하는데, 그 그림을 무엇으로 만들지가 다음 결정이었다.

이 블로그 더 보기

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

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

답글 남기기

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