fix(stage-tamagotchi): live2d focus issue & added useElectronAllDisplays, useElectronRelativeMouse, and updated devtools

This commit is contained in:
Neko Ayaka
2025-10-19 01:38:21 +08:00
parent 78a9a77cc8
commit ab001a7ab9
19 changed files with 244 additions and 97 deletions
@@ -1 +1,2 @@
export * from './screen'
export * from './window'
@@ -4,9 +4,23 @@ import type { BrowserWindow } from 'electron'
import { defineInvokeHandler } from '@unbird/eventa'
import { screen } from 'electron'
import { cursorScreenPoint, startLoopGetCursorScreenPoint } from '../../../shared/electron/screen'
import { electron } from '../../../shared/eventa'
import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle'
import { useLoop } from '../../libs/event-loop'
export function createScreenService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) {
const { start, stop } = useLoop(() => {
const dipPos = screen.getCursorScreenPoint()
params.context.emit(cursorScreenPoint, dipPos)
}, {
autoStart: false,
})
onAppWindowAllClosed(() => stop())
onAppBeforeQuit(() => stop())
defineInvokeHandler(params.context, startLoopGetCursorScreenPoint, () => start())
defineInvokeHandler(params.context, electron.screen.getAllDisplays, () => screen.getAllDisplays())
defineInvokeHandler(params.context, electron.screen.getPrimaryDisplay, () => screen.getPrimaryDisplay())
defineInvokeHandler(params.context, electron.screen.dipToScreenPoint, point => screen.dipToScreenPoint(point))
@@ -0,0 +1,27 @@
import type { createContext } from '@unbird/eventa/adapters/electron/main'
import type { BrowserWindow } from 'electron'
import { defineInvokeHandler } from '@unbird/eventa'
import { bounds, startLoopGetBounds } from '../../../shared/electron/window'
import { electron } from '../../../shared/eventa'
import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle'
import { useLoop } from '../../libs/event-loop'
export function createWindowService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) {
const { start, stop } = useLoop(() => {
if (params.window.isDestroyed()) {
return
}
params.context.emit(bounds, params.window.getBounds())
}, {
autoStart: false,
})
onAppWindowAllClosed(() => stop())
onAppBeforeQuit(() => stop())
defineInvokeHandler(params.context, startLoopGetBounds, () => start())
defineInvokeHandler(params.context, electron.window.getBounds, () => params.window.getBounds())
}
@@ -1,5 +0,0 @@
import type { createContext } from '@unbird/eventa/adapters/electron/main'
export function createFadeOnHoverService(_context: ReturnType<typeof createContext>['context']) {
}
@@ -1,21 +0,0 @@
import type { createContext } from '@unbird/eventa/adapters/electron/main'
import { defineInvokeHandler } from '@unbird/eventa'
import { screen } from 'electron'
import { electronMousePosition, electronStartTrackMousePosition } from '../../../shared/eventa'
import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle'
import { useLoop } from '../../libs/event-loop'
export function createMouseService(context: ReturnType<typeof createContext>['context']) {
const { start, stop } = useLoop(() => {
const dipPos = screen.getCursorScreenPoint()
context.emit(electronMousePosition, dipPos)
}, {
autoStart: false,
})
onAppWindowAllClosed(() => stop())
onAppBeforeQuit(() => stop())
defineInvokeHandler(context, electronStartTrackMousePosition, () => start())
}
@@ -5,17 +5,14 @@ import { createContext } from '@unbird/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenMainDevtools, electronOpenSettings } from '../../../../shared/eventa'
import { createScreenService } from '../../../services/electron'
import { createFadeOnHoverService } from '../../../services/fade-on-hover'
import { createMouseService } from '../../../services/misc/mouse'
import { createScreenService, createWindowService } from '../../../services/electron'
import { toggleWindowShow } from '../../shared'
export function setupMainWindowElectronInvokes(params: { window: BrowserWindow, settingsWindow: () => Promise<BrowserWindow> }) {
const { context } = createContext(ipcMain, params.window)
createFadeOnHoverService(context)
createMouseService(context)
createScreenService({ context, window: params.window })
createWindowService({ context, window: params.window })
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow()))
@@ -5,14 +5,13 @@ import { createContext } from '@unbird/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenSettingsDevtools } from '../../../../shared/eventa'
import { createScreenService } from '../../../services/electron'
import { createFadeOnHoverService } from '../../../services/fade-on-hover'
import { createScreenService, createWindowService } from '../../../services/electron'
export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow }) {
const { context } = createContext(ipcMain, params.settingsWindow)
createFadeOnHoverService(context)
createScreenService({ context, window: params.settingsWindow })
createWindowService({ context, window: params.settingsWindow })
defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
}
@@ -3,7 +3,7 @@ import { RouterView } from 'vue-router'
</script>
<template>
<div h-full w-full p-4 flex="~ col gap-4">
<div h-full w-full flex="~ col gap-4">
<RouterView />
</div>
</template>
@@ -1,29 +1,15 @@
<script setup lang="ts">
import { defineInvoke } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/renderer'
import { computedAsync, useIntervalFn, useWindowSize } from '@vueuse/core'
import { useWindowSize } from '@vueuse/core'
import { computed, ref } from 'vue'
import { electron } from '../../../shared/electron'
import { useElectronAllDisplays, useElectronMouse } from '../../stores/window'
const { context: ctx } = createContext(window.electron.ipcRenderer)
const getAllDisplays = defineInvoke(ctx, electron.screen.getAllDisplays)
const getCursorScreenPoint = defineInvoke(ctx, electron.screen.getCursorScreenPoint)
const allDisplays = useElectronAllDisplays()
const { x: cursorX, y: cursorY } = useElectronMouse()
const allDisplays = computedAsync(async () => getAllDisplays(), [])
const cursorPoint = ref({ x: 0, y: 0 })
// Update cursor position periodically
useIntervalFn(async () => {
const newPoint = await getCursorScreenPoint()
cursorPoint.value = newPoint
}, 1000 / 60)
// Get container element and its bounds
const containerRef = ref<HTMLElement>()
const windowSize = useWindowSize()
// Calculate bounds for all displays
const displayBounds = computed(() => {
if (!allDisplays.value.length)
return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 }
@@ -85,8 +71,8 @@ function transformDisplay(display: any) {
const transformedCursor = computed(() => {
const { minX, minY } = displayBounds.value
return {
x: (cursorPoint.value.x - minX) * scale.value,
y: (cursorPoint.value.y - minY) * scale.value,
x: (cursorX.value - minX) * scale.value,
y: (cursorY.value - minY) * scale.value,
}
})
@@ -148,7 +134,7 @@ const containerDimensions = computed(() => {
}"
>
<div class="absolute left-3 whitespace-nowrap text-[11px] text-primary-400 font-bold -top-1.25">
{{ cursorPoint.x }}, {{ cursorPoint.y }}
{{ cursorX }}, {{ cursorY }}
</div>
</div>
</div>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { useElectronMouse, useElectronRelativeMouse, useElectronWindowBounds } from '../../stores/window'
const { x: cursorX, y: cursorY } = useElectronMouse()
const windowRelativeMouse = useElectronRelativeMouse()
const windowBounds = useElectronWindowBounds()
</script>
<template>
<div class="space-y-6">
<div class="rounded bg-neutral-100 p-3 text-xs font-mono space-y-2 dark:bg-neutral-800">
<div>windowX = screenX - windowBounds.x</div>
<div>windowY = screenY - windowBounds.y</div>
<div class="border-t border-neutral-300 pt-2 dark:border-neutral-700">
<div>
{{ windowRelativeMouse.x.value }} = {{ cursorX }} - {{ windowBounds.x.value }}
</div>
<div>
{{ windowRelativeMouse.y.value }} = {{ cursorY }} - {{ windowBounds.y.value }}
</div>
</div>
</div>
<!-- Visual Representation -->
<div class="relative border-2 border-neutral-300 rounded bg-white p-8 dark:border-neutral-700 dark:bg-neutral-950" style="height: 400px;">
<!-- Window representation -->
<div class="absolute inset-8 border-2 border-primary-500 rounded bg-primary-50/20 dark:bg-primary-950/20">
<div class="absolute left-0 text-xs text-primary-600 font-semibold -top-6">
Window ({{ windowBounds.width.value }}×{{ windowBounds.height.value }})
</div>
<!-- Center crosshair -->
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div class="h-8 w-0.5 bg-neutral-300 dark:bg-neutral-700" />
<div class="absolute left-1/2 top-1/2 h-0.5 w-8 bg-neutral-300 -translate-x-1/2 -translate-y-1/2 dark:bg-neutral-700" />
<div class="absolute left-1/2 top-6 whitespace-nowrap text-[10px] text-neutral-500 -translate-x-1/2">
Center ({{ Math.round(windowBounds.width.value / 2) }}, {{ Math.round(windowBounds.height.value / 2) }})
</div>
</div>
<!-- Current cursor position (scaled to fit) -->
<div
class="absolute h-3 w-3 border-2 border-green-600 rounded-full bg-green-500 -translate-x-1/2 -translate-y-1/2"
:style="{
left: `${Math.max(0, Math.min(100, (windowRelativeMouse.x.value / windowBounds.width.value) * 100))}%`,
top: `${Math.max(0, Math.min(100, (windowRelativeMouse.y.value / windowBounds.height.value) * 100))}%`,
}"
>
<div class="absolute left-4 whitespace-nowrap text-[10px] text-green-600 font-semibold -top-1">
({{ Math.round(windowRelativeMouse.x.value) }}, {{ Math.round(windowRelativeMouse.y.value) }})
</div>
</div>
<!-- Corner labels -->
<div class="absolute text-[10px] text-neutral-500 -left-5 -top-5">
(0, 0)
</div>
<div class="absolute text-[10px] text-neutral-500 -right-5 -top-5">
({{ windowBounds.width.value }}, 0)
</div>
<div class="absolute text-[10px] text-neutral-500 -bottom-5 -left-5">
(0, {{ windowBounds.height.value }})
</div>
<div class="absolute text-[10px] text-neutral-500 -bottom-5 -right-5">
({{ windowBounds.width.value }}, {{ windowBounds.height.value }})
</div>
</div>
<div class="absolute left-0 text-xs text-neutral-600 -bottom-6 dark:text-neutral-400">
Green dot shows current window-relative cursor position
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
</route>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useWindowMouse } from '../../stores/window-mouse'
import { useElectronMouse } from '../../stores/window'
const { x, y } = useWindowMouse()
const { x, y } = useElectronMouse()
</script>
<template>
@@ -19,8 +19,7 @@ export interface Point {
const windowControlStore = useWindowControlStore()
const { scale, positionInPercentageString } = storeToRefs(useLive2d())
const { centerPos, live2dLookAtX, live2dLookAtY } = storeToRefs(useWindowStore())
const live2dFocusAt = ref<Point>(centerPos.value)
const { live2dLookAtX, live2dLookAtY } = storeToRefs(useWindowStore())
const widgetStageRef = ref<{ canvasElement: () => HTMLCanvasElement }>()
const resourceStatusIslandRef = ref<InstanceType<typeof ResourceStatusIsland>>()
@@ -30,7 +29,6 @@ const isLoading = ref(true)
const componentStateStage = ref<'pending' | 'loading' | 'mounted'>('pending')
watch(componentStateStage, () => isLoading.value = componentStateStage.value !== 'mounted', { immediate: true })
watch([live2dLookAtX, live2dLookAtY], ([x, y]) => live2dFocusAt.value = { x, y }, { immediate: true })
const modeIndicatorClass = computed(() => {
switch (windowControlStore.controlMode) {
@@ -64,7 +62,7 @@ const modeIndicatorClass = computed(() => {
v-model:state="componentStateStage"
h-full w-full
flex-1
:focus-at="live2dFocusAt"
:focus-at="{ x: live2dLookAtX, y: live2dLookAtY }"
:scale="scale"
:x-offset="positionInPercentageString.x"
:y-offset="positionInPercentageString.y"
@@ -28,7 +28,13 @@ const menu = computed(() => [
title: 'Displays',
description: 'Visualize connected displays and cursor position',
icon: 'i-solar:sledgehammer-bold-duotone',
to: '/devtools/electron-display',
to: '/devtools/use-electron-all-displays',
},
{
title: 'Relative Mouse',
description: 'Get mouse position relative to the window',
icon: 'i-solar:sledgehammer-bold-duotone',
to: '/devtools/use-electron-relative-mouse',
},
])
@@ -80,7 +86,7 @@ const openDevTools = defineInvoke(context, electronOpenMainDevtools)
transition="all ease-in-out duration-250"
/>
<div flex="~ col gap-4" pb-12>
<div flex="~ col gap-4" mt-2 pb-12>
<IconItem
v-for="(item, index) in menu"
:key="item.to"
@@ -1,24 +0,0 @@
import { defineInvoke } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/renderer'
import { useMouse } from '@vueuse/core'
import { ref } from 'vue'
import { electronMousePosition, electronStartTrackMousePosition } from '../../shared/eventa'
export function useWindowMouse(options?: { x: number, y: number }) {
const eventTarget = useElectronMouseEventTarget()
return useMouse({ target: eventTarget, type: 'screen', initialValue: options })
}
export function useElectronMouseEventTarget() {
const context = ref(createContext(window.electron.ipcRenderer).context)
const eventTarget = ref(new EventTarget())
context.value.on(electronMousePosition, (event) => {
const e = new MouseEvent('mousemove', { screenX: event.body?.x, screenY: event.body?.y })
eventTarget.value.dispatchEvent(e)
})
defineInvoke(context.value!, electronStartTrackMousePosition)()
return eventTarget
}
@@ -1,16 +1,91 @@
import { useWindowSize } from '@vueuse/core'
import { defineInvoke } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/renderer'
import { useAsyncState, useIntervalFn, useMouse, useWindowSize } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { cursorScreenPoint, startLoopGetCursorScreenPoint } from '../../shared/electron/screen'
import { bounds, startLoopGetBounds } from '../../shared/electron/window'
import { electron } from '../../shared/eventa'
import { useWindowControlStore } from './window-controls'
import { useWindowMouse } from './window-mouse'
export function useElectronMouseEventTarget() {
const context = ref(createContext(window.electron.ipcRenderer).context)
const eventTarget = ref(new EventTarget())
context.value.on(cursorScreenPoint, (event) => {
const e = new MouseEvent('mousemove', { screenX: event.body?.x, screenY: event.body?.y })
eventTarget.value.dispatchEvent(e)
})
defineInvoke(context.value!, startLoopGetCursorScreenPoint)()
return eventTarget
}
export function useElectronMouse(options?: { x: number, y: number }) {
const eventTarget = useElectronMouseEventTarget()
return useMouse({ target: eventTarget, type: 'screen', initialValue: options })
}
export function useElectronAllDisplays() {
const context = ref(createContext(window.electron.ipcRenderer).context)
const getAllDisplays = defineInvoke(context.value, electron.screen.getAllDisplays)
const { state: allDisplays, execute } = useAsyncState(() => getAllDisplays(), [])
useIntervalFn(() => {
execute()
}, 5000)
return allDisplays
}
export function useElectronWindowBounds() {
const context = ref(createContext(window.electron.ipcRenderer).context)
const windowBoundsX = ref(0)
const windowBoundsY = ref(0)
const windowBoundsWidth = ref(0)
const windowBoundsHeight = ref(0)
context.value.on(bounds, (event) => {
if (!event || !event.body)
return
windowBoundsX.value = event.body.x
windowBoundsY.value = event.body.y
windowBoundsWidth.value = event.body.width
windowBoundsHeight.value = event.body.height
})
defineInvoke(context.value!, startLoopGetBounds)()
return {
x: windowBoundsX,
y: windowBoundsY,
width: windowBoundsWidth,
height: windowBoundsHeight,
}
}
export function useElectronRelativeMouse(initialValue?: { x: number, y: number }) {
const { x: mouseX, y: mouseY } = useElectronMouse(initialValue)
const { x: windowX, y: windowY } = useElectronWindowBounds()
// Transform screen coordinates to window-relative coordinates
const x = computed(() => mouseX.value - windowX.value)
const y = computed(() => mouseY.value - windowY.value)
return { x, y }
}
export const useWindowStore = defineStore('tamagotchi-window', () => {
const windowControlStore = useWindowControlStore()
const { width, height } = useWindowSize()
const centerPos = computed(() => ({ x: width.value / 2, y: height.value / 2 }))
const { x: live2dLookAtX, y: live2dLookAtY } = useWindowMouse(centerPos.value)
// Use window-relative mouse coordinates for Live2D focus
// Transforms screen coordinates to window-relative coordinates
const { x: live2dLookAtX, y: live2dLookAtY } = useElectronRelativeMouse(centerPos.value)
const isCursorInside = ref(false)
const shouldHideView = computed(() => isCursorInside.value && !windowControlStore.isControlActive && windowControlStore.isIgnoringMouseEvent)
@@ -1,5 +1,7 @@
import { screen } from './screen'
import { window } from './window'
export const electron = {
screen,
window,
}
@@ -1,6 +1,9 @@
import type { Display, screen as electronScreen } from 'electron'
import type { Display, screen as electronScreen, Point } from 'electron'
import { defineInvokeEventa } from '@unbird/eventa'
import { defineEventa, defineInvokeEventa } from '@unbird/eventa'
export const cursorScreenPoint = defineEventa<Point>('eventa:event:electron:screen:cursor-screen-point')
export const startLoopGetCursorScreenPoint = defineInvokeEventa('eventa:event:electron:screen:start-loop-get-cursor-screen-point')
const getAllDisplays = defineInvokeEventa<Display[]>('eventa:invoke:electron:screen:get-all-displays')
const getPrimaryDisplay = defineInvokeEventa<Display>('eventa:invoke:electron:screen:get-primary-display')
@@ -0,0 +1,12 @@
import type { BrowserWindow, Rectangle } from 'electron'
import { defineEventa, defineInvokeEventa } from '@unbird/eventa'
export const bounds = defineEventa<Rectangle>('eventa:event:electron:window:bounds')
export const startLoopGetBounds = defineInvokeEventa('eventa:event:electron:window:start-loop-get-bounds')
const getBounds = defineInvokeEventa<ReturnType<BrowserWindow['getBounds']>>('eventa:invoke:electron:window:get-bounds')
export const window = {
getBounds,
}
+1 -4
View File
@@ -1,8 +1,5 @@
import type { Point } from 'electron'
import { defineInvokeEventa } from '@unbird/eventa'
import { defineEventa, defineInvokeEventa } from '@unbird/eventa'
export const electronMousePosition = defineEventa<Point>('electron:eventa:event:mouse-position')
export const electronStartTrackMousePosition = defineInvokeEventa('electron:eventa:invoke:start-tracking-mouse-position')
export const electronStartDraggingWindow = defineInvokeEventa('electron:eventa:invoke:start-dragging-window')
export const electronOpenMainDevtools = defineInvokeEventa('electron:eventa:invoke:windows:main:devtools:open')