Skip to content

Commit ed322d4

Browse files
skirkrunekomeowww
andauthored
feat(stage-tamagotchi): Make window passthrough outside of Live2D and VRM models (#437)
--------- Co-authored-by: Neko <neko@ayaka.moe>
1 parent 2f54547 commit ed322d4

File tree

11 files changed

+259
-49
lines changed

11 files changed

+259
-49
lines changed

apps/stage-tamagotchi/src/bindings/tauri-plugins/window-pass-through-on-hover.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,40 @@
44

55
/** user-defined commands **/
66

7+
let passThroughEnabled = false;
78

89
export const commands = {
9-
async startPassThrough() : Promise<Result<null, string>> {
10+
async startPassThrough(): Promise<Result<null, string>> {
11+
if (passThroughEnabled) {
12+
return { status: 'ok', data: null };
13+
}
1014
try {
11-
return { status: "ok", data: await TAURI_INVOKE("plugin:window-pass-through-on-hover|start_pass_through") };
12-
} catch (e) {
13-
if(e instanceof Error) throw e;
14-
else return { status: "error", error: e as any };
15-
}
16-
},
17-
async stopPassThrough() : Promise<Result<null, string>> {
15+
passThroughEnabled = true;
16+
return { status: 'ok', data: await TAURI_INVOKE('plugin:window-pass-through-on-hover|start_pass_through') };
17+
}
18+
catch (e) {
19+
passThroughEnabled = false;
20+
if (e instanceof Error)
21+
throw e;
22+
else return { status: 'error', error: e as any };
23+
}
24+
},
25+
async stopPassThrough(): Promise<Result<null, string>> {
26+
if (!passThroughEnabled) {
27+
return { status: 'ok', data: null };
28+
}
1829
try {
19-
return { status: "ok", data: await TAURI_INVOKE("plugin:window-pass-through-on-hover|stop_pass_through") };
20-
} catch (e) {
21-
if(e instanceof Error) throw e;
22-
else return { status: "error", error: e as any };
23-
}
24-
}
25-
}
30+
passThroughEnabled = false;
31+
return { status: 'ok', data: await TAURI_INVOKE('plugin:window-pass-through-on-hover|stop_pass_through') };
32+
}
33+
catch (e) {
34+
passThroughEnabled = true;
35+
if (e instanceof Error)
36+
throw e;
37+
else return { status: 'error', error: e as any };
38+
}
39+
}
40+
};
2641

2742
/** user-defined events **/
2843

apps/stage-tamagotchi/src/composables/tauri-rdev.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ export function useTauriRdevEventTarget(): EventTarget {
4242
eventTarget.dispatchEvent(e)
4343
})
4444

45+
unListenFuncs.push(await listen('tauri-plugins:tauri-plugin-rdev:mousemove', (event) => {
46+
if (event.payload.event_type.MouseMove) {
47+
const { x, y } = event.payload.event_type.MouseMove
48+
const e = new MouseEvent('mousemove', {
49+
clientX: x,
50+
clientY: y,
51+
})
52+
eventTarget.dispatchEvent(e)
53+
}
54+
}))
55+
4556
unListenFuncs.push(await listen('tauri-plugins:tauri-plugin-rdev:keyup', (event) => {
4657
if (typeof event.payload.event_type.KeyRelease === 'object' && 'Unknown' in event.payload.event_type.KeyRelease) {
4758
if (event.payload.event_type.KeyRelease.Unknown === 62) {

apps/stage-tamagotchi/src/composables/tauri.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface WindowFrame {
3636

3737
interface Events {
3838
'tauri://resize': unknown
39-
'tauri://move': unknown
39+
'tauri://move': { payload: { x: number, y: number } }
4040
'tauri://close-requested': unknown
4141
'tauri://destroyed': unknown
4242
'tauri://focus': unknown
@@ -72,6 +72,7 @@ export interface AiriTamagotchiEvents extends Events {
7272
'tauri-plugins:tauri-plugin-rdev:keyup': { time: { secs_since_epoch: number, nanos_since_epoch: number }, name: string, event_type: { KeyRelease: KeyCode | { Unknown: number } } } // similar to 'keyup' events from DOM elements
7373
'tauri-plugins:tauri-plugin-rdev:mousedown': { time: { secs_since_epoch: number, nanos_since_epoch: number }, name: string, event_type: { ButtonPress: string } } // similar to 'mousedown' events from DOM elements
7474
'tauri-plugins:tauri-plugin-rdev:mouseup': { time: { secs_since_epoch: number, nanos_since_epoch: number }, name: string, event_type: { ButtonRelease: string } } // similar to 'mouseup' events from DOM elements
75+
'tauri-plugins:tauri-plugin-rdev:mousemove': { time: { secs_since_epoch: number, nanos_since_epoch: number }, name: string, event_type: { MouseMove: { x: number, y: number } } } // similar to 'mousemove' events from DOM elements
7576

7677
// MCP
7778
'mcp_plugin_destroyed': undefined
@@ -288,6 +289,18 @@ export function useTauriWindow() {
288289
}
289290
}
290291

292+
async function getPosition() {
293+
try {
294+
const imported = await _ensureImported()
295+
const window = imported.getCurrentWindow()
296+
return await window.innerPosition()
297+
}
298+
catch (error) {
299+
console.error('Failed to get window position:', error)
300+
return undefined
301+
}
302+
}
303+
291304
async function closeWindow(label?: string) {
292305
try {
293306
const imported = await _ensureImported()
@@ -316,6 +329,7 @@ export function useTauriWindow() {
316329
getCurrentMonitor,
317330
getPrimaryMonitor,
318331
setPosition,
332+
getPosition,
319333
closeWindow,
320334
}
321335
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { AiriTamagotchiEvents } from './tauri'
2+
3+
import { createSharedComposable } from '@vueuse/core'
4+
import { ref } from 'vue'
5+
6+
import { useTauriEvent } from './tauri'
7+
8+
export const useRdevMouse = createSharedComposable(() => {
9+
const mouseX = ref(0)
10+
const mouseY = ref(0)
11+
12+
const { listen } = useTauriEvent<AiriTamagotchiEvents>()
13+
14+
async function setup() {
15+
await listen('tauri-plugins:tauri-plugin-rdev:mousemove', (event) => {
16+
if (event.payload.event_type.MouseMove) {
17+
const { x, y } = event.payload.event_type.MouseMove
18+
mouseX.value = x
19+
mouseY.value = y
20+
}
21+
})
22+
}
23+
24+
setup()
25+
26+
return {
27+
mouseX,
28+
mouseY,
29+
}
30+
})

apps/stage-tamagotchi/src/pages/index.vue

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
55
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
66
import { useMcpStore } from '@proj-airi/stage-ui/stores/mcp'
77
import { connectServer } from '@proj-airi/tauri-plugin-mcp'
8+
import { watchThrottled } from '@vueuse/core'
89
import { storeToRefs } from 'pinia'
910
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
1011
1112
import ResourceStatusIsland from '../components/Widgets/ResourceStatusIsland/index.vue'
1213
13-
import { useTauriCore, useTauriEvent } from '../composables/tauri'
14+
import { commands as passThroughCommands } from '../bindings/tauri-plugins/window-pass-through-on-hover'
15+
import { useTauriCore, useTauriEvent, useTauriWindow } from '../composables/tauri'
1416
import { useTauriGlobalShortcuts } from '../composables/tauri-global-shortcuts'
17+
import { useRdevMouse } from '../composables/use-rdev-mouse'
1518
import { useResourcesStore } from '../stores/resources'
1619
import { useWindowStore } from '../stores/window'
1720
import { useWindowControlStore } from '../stores/window-controls'
@@ -21,14 +24,117 @@ useTauriGlobalShortcuts()
2124
const windowControlStore = useWindowControlStore()
2225
const resourcesStore = useResourcesStore()
2326
const mcpStore = useMcpStore()
27+
const { getPosition } = useTauriWindow()
28+
const { mouseX, mouseY } = useRdevMouse()
2429
2530
const { listen } = useTauriEvent<AiriTamagotchiEvents>()
2631
const { invoke } = useTauriCore()
2732
const { connected, serverCmd, serverArgs } = storeToRefs(mcpStore)
2833
const { scale, positionInPercentageString } = storeToRefs(useLive2d())
2934
30-
const { centerPos, live2dLookAtX, live2dLookAtY, shouldHideView } = storeToRefs(useWindowStore())
35+
const { centerPos, live2dLookAtX, live2dLookAtY } = storeToRefs(useWindowStore())
3136
const live2dFocusAt = ref<Point>(centerPos.value)
37+
const widgetStageRef = ref<{ canvasElement: () => HTMLCanvasElement }>()
38+
const resourceStatusIslandRef = ref<InstanceType<typeof ResourceStatusIsland>>()
39+
const buttonsContainerRef = ref<HTMLDivElement>()
40+
const windowX = ref(0)
41+
const windowY = ref(0)
42+
const isClickThrough = ref(false)
43+
const isPassingThrough = ref(false)
44+
const isOverUI = ref(false)
45+
46+
watchThrottled([mouseX, mouseY], async ([x, y]) => {
47+
const canvas = widgetStageRef.value?.canvasElement()
48+
if (!canvas)
49+
return
50+
51+
if (windowControlStore.controlMode === WindowControlMode.RESIZE || windowControlStore.controlMode === WindowControlMode.MOVE) {
52+
if (isPassingThrough.value) {
53+
passThroughCommands.stopPassThrough()
54+
isPassingThrough.value = false
55+
}
56+
return
57+
}
58+
59+
const relativeX = x - windowX.value
60+
const relativeY = y - windowY.value
61+
62+
const islandEl = resourceStatusIslandRef.value?.$el as HTMLElement
63+
const buttonsEl = buttonsContainerRef.value
64+
65+
isOverUI.value = false
66+
if (!windowControlStore.isIgnoringMouseEvent) {
67+
if (islandEl) {
68+
const rect = islandEl.getBoundingClientRect()
69+
if (relativeX >= rect.left && relativeX <= rect.right && relativeY >= rect.top && relativeY <= rect.bottom)
70+
isOverUI.value = true
71+
}
72+
if (!isOverUI.value && buttonsEl) {
73+
const rect = buttonsEl.getBoundingClientRect()
74+
if (relativeX >= rect.left && relativeX <= rect.right && relativeY >= rect.top && relativeY <= rect.bottom)
75+
isOverUI.value = true
76+
}
77+
78+
if (isOverUI.value) {
79+
if (isPassingThrough.value) {
80+
passThroughCommands.stopPassThrough()
81+
isPassingThrough.value = false
82+
}
83+
return
84+
}
85+
}
86+
87+
let isTransparent = false
88+
if (
89+
!isOverUI.value
90+
&& relativeX >= 0
91+
&& relativeX < canvas.clientWidth
92+
&& relativeY >= 0
93+
&& relativeY < canvas.clientHeight
94+
) {
95+
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')
96+
if (gl) {
97+
const pixelX = relativeX * (gl.drawingBufferWidth / canvas.clientWidth)
98+
const pixelY
99+
= gl.drawingBufferHeight
100+
- relativeY * (gl.drawingBufferHeight / canvas.clientHeight)
101+
102+
const data = new Uint8Array(4)
103+
gl.readPixels(
104+
Math.floor(pixelX),
105+
Math.floor(pixelY),
106+
1,
107+
1,
108+
gl.RGBA,
109+
gl.UNSIGNED_BYTE,
110+
data,
111+
)
112+
isTransparent = data[3] < 100 // Use a small threshold for anti-aliasing
113+
}
114+
}
115+
else {
116+
isTransparent = true
117+
}
118+
119+
isClickThrough.value = isTransparent
120+
121+
if (windowControlStore.isIgnoringMouseEvent) {
122+
if (!isPassingThrough.value) {
123+
passThroughCommands.startPassThrough()
124+
isPassingThrough.value = true
125+
}
126+
return
127+
}
128+
129+
if (isTransparent && !isPassingThrough.value) {
130+
passThroughCommands.startPassThrough()
131+
isPassingThrough.value = true
132+
}
133+
else if (!isTransparent && isPassingThrough.value) {
134+
passThroughCommands.stopPassThrough()
135+
isPassingThrough.value = false
136+
}
137+
}, { throttle: 33 })
32138
33139
watch([live2dLookAtX, live2dLookAtY], ([x, y]) => live2dFocusAt.value = { x, y }, { immediate: true })
34140
@@ -82,6 +188,16 @@ async function setupWhisperModel() {
82188
}
83189
84190
onMounted(async () => {
191+
const pos = await getPosition()
192+
if (pos) {
193+
windowX.value = pos.x
194+
windowY.value = pos.y
195+
}
196+
unListenFuncs.push(await listen('tauri://move', (event) => {
197+
windowX.value = event.payload.payload.x
198+
windowY.value = event.payload.payload.y
199+
}))
200+
85201
await setupVADModel()
86202
await setupWhisperModel()
87203
@@ -118,7 +234,8 @@ if (import.meta.hot) { // For better DX
118234
<template>
119235
<div
120236
:class="[modeIndicatorClass, {
121-
'op-0': shouldHideView,
237+
'op-0': windowControlStore.isIgnoringMouseEvent && !isClickThrough,
238+
'pointer-events-none': !isClickThrough,
122239
}]"
123240
max-h="[100vh]"
124241
max-w="[100vw]"
@@ -128,17 +245,19 @@ if (import.meta.hot) { // For better DX
128245
>
129246
<div relative h-full w-full items-end gap-2 class="view">
130247
<WidgetStage
248+
ref="widgetStageRef"
131249
h-full w-full flex-1
132250
:focus-at="live2dFocusAt" :scale="scale"
133251
:x-offset="positionInPercentageString.x"
134252
:y-offset="positionInPercentageString.y" mb="<md:18"
135253
/>
136-
<ResourceStatusIsland />
254+
<ResourceStatusIsland ref="resourceStatusIslandRef" />
137255
<div
256+
ref="buttonsContainerRef"
138257
absolute bottom-4 left-4 flex gap-1 op-0 transition="opacity duration-500"
139258
:class="{
140-
'pointer-events-none': windowControlStore.isControlActive,
141-
'show-on-hover': !windowControlStore.isIgnoringMouseEvent,
259+
'pointer-events-none': isClickThrough && !isOverUI,
260+
'show-on-hover': !windowControlStore.isIgnoringMouseEvent && (!isClickThrough || isOverUI),
142261
}"
143262
>
144263
<div
@@ -212,10 +331,8 @@ if (import.meta.hot) { // For better DX
212331
.view {
213332
transition: opacity 0.5s ease-in-out;
214333
215-
&:hover {
216-
.show-on-hover {
217-
opacity: 1;
218-
}
334+
.show-on-hover {
335+
opacity: 1;
219336
}
220337
}
221338

apps/stage-tamagotchi/src/stores/shortcuts.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { defineStore } from 'pinia'
55
import { ref, toValue, watch } from 'vue'
66

77
import { WindowControlMode } from '../types/window-controls'
8-
import { startClickThrough, stopClickThrough } from '../utils/windows'
98
import { useWindowControlStore } from './window-controls'
109

1110
interface Versioned<T> { version?: string, data?: T }
@@ -96,12 +95,6 @@ export const useShortcutsStore = defineStore('shortcuts', () => {
9695
type: 'ignore-mouse-event',
9796
handle: async () => {
9897
windowStore.isIgnoringMouseEvent = !windowStore.isIgnoringMouseEvent
99-
if (windowStore.isIgnoringMouseEvent) {
100-
await startClickThrough()
101-
return
102-
}
103-
104-
await stopClickThrough()
10598
},
10699
},
107100
])

apps/stage-tamagotchi/src/stores/window-controls.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,6 @@ export const useWindowMode = defineStore('window-mode', () => {
5555

5656
unlistenFuncs.value.push(await listen('tauri-main:main:window-mode:fade-on-hover', async () => {
5757
windowStore.isIgnoringMouseEvent = !windowStore.isIgnoringMouseEvent
58-
if (windowStore.isIgnoringMouseEvent) {
59-
await startClickThrough()
60-
return
61-
}
62-
63-
await stopClickThrough()
6458
}))
6559
}
6660

0 commit comments

Comments
 (0)