fix(stage-tamagotchi): synchronize fade and click-through (#2210)

This commit is contained in:
Neko
2026-08-04 15:10:24 +08:00
committed by GitHub
parent 93af669d14
commit 9ef61afcd6
3 changed files with 106 additions and 21 deletions
@@ -41,6 +41,7 @@ import { modelSettingsRuntimeSnapshotChannelName } from '../../shared/model-sett
import { useChatSyncStore } from '../stores/chat-sync'
import { useControlsIslandStore } from '../stores/controls-island'
import { useStageWindowLifecycleStore } from '../stores/stage-window-lifecycle'
import { resolveFadeOnHoverInteraction } from '../utils/fade-on-hover'
import { shouldSampleStageTransparency } from '../utils/stage-three-transparency'
import { createVoiceInputInteractionLifecycle } from '../utils/voice-input-lifecycle'
import {
@@ -139,10 +140,6 @@ const isAroundWindowBorderFor250Ms = refDebounced(isAroundWindowBorder, 250)
const setIgnoreMouseEvents = useElectronEventaInvoke(electron.window.setIgnoreMouseEvents)
const { pause, resume } = watch(isTransparent, (transparent) => {
shouldFadeOnCursorWithin.value = fadeOnHoverEnabled.value && !transparent
}, { immediate: true })
const hearingDialogOpen = computed(() => controlsIslandRef.value?.hearingDialogOpen ?? false)
const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() => {
@@ -236,12 +233,29 @@ const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() =
})
})
watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, hearingDialogOpen, fadeOnHoverEnabled, stagePaused], () => {
/**
* Keeps the rendered fade state and Electron click-through state synchronized.
*
* Triggering workflow:
*
* {@link watch}
* -> `fade-on-hover reactive state change`
* -> {@link handleFadeOnHoverInteractionChange}
*
* Upstream:
* - {@link isOutsideFor250Ms}, {@link isOutsideStatusIslandFor250Ms}, and {@link isAroundWindowBorderFor250Ms}
* - {@link isOutsideWindow}, {@link isTransparent}, and {@link isTransparentForMouseEvents}
* - {@link hearingDialogOpen}, {@link fadeOnHoverEnabled}, and {@link stagePaused}
*
* Downstream:
* - {@link resolveFadeOnHoverInteraction}
* - {@link setIgnoreMouseEvents}
*/
function handleFadeOnHoverInteractionChange() {
if (stagePaused.value) {
isIgnoringMouseEvents.value = false
shouldFadeOnCursorWithin.value = false
setIgnoreMouseEvents([false, { forward: true }])
pause()
return
}
@@ -250,7 +264,6 @@ watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor
isIgnoringMouseEvents.value = false
shouldFadeOnCursorWithin.value = false
setIgnoreMouseEvents([false, { forward: true }])
pause()
return
}
@@ -262,23 +275,26 @@ watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor
isIgnoringMouseEvents.value = false
shouldFadeOnCursorWithin.value = false
setIgnoreMouseEvents([false, { forward: true }])
pause()
}
else {
const fadeEnabled = fadeOnHoverEnabled.value
// Keep visible model pixels interactive; only the exact transparent pixel under the cursor
// should pass clicks through. The fuzzy transparency value above is intentionally reserved
// for fade stability near model edges.
const shouldIgnoreMouseEvents = fadeEnabled && isTransparentForMouseEvents.value
isIgnoringMouseEvents.value = shouldIgnoreMouseEvents
shouldFadeOnCursorWithin.value = fadeEnabled && !isOutsideWindow.value && !isTransparent.value
setIgnoreMouseEvents([shouldIgnoreMouseEvents, { forward: true }])
if (fadeEnabled)
resume()
else
pause()
const interaction = resolveFadeOnHoverInteraction({
cursorInsideWindow: !isOutsideWindow.value,
enabled: fadeOnHoverEnabled.value,
transparentForFade: isTransparent.value,
transparentForPointer: isTransparentForMouseEvents.value,
})
isIgnoringMouseEvents.value = interaction.ignoreMouseEvents
shouldFadeOnCursorWithin.value = interaction.fadeStage
setIgnoreMouseEvents([interaction.ignoreMouseEvents, { forward: true }])
}
})
}
watch(
[isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, isTransparentForMouseEvents, hearingDialogOpen, fadeOnHoverEnabled, stagePaused],
handleFadeOnHoverInteractionChange,
{ immediate: true },
)
// Emit runtime snapshot on change and on request from settings panel
/**
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { resolveFadeOnHoverInteraction } from './fade-on-hover'
describe('fade on hover interaction', () => {
it('lets pointer input reach the underlying app when a visible model fades', () => {
const interaction = resolveFadeOnHoverInteraction({
cursorInsideWindow: true,
enabled: true,
transparentForFade: false,
transparentForPointer: false,
})
expect(interaction.fadeStage).toBe(true)
expect(interaction.ignoreMouseEvents).toBe(true)
})
it('keeps an unfaded transparent stage click-through', () => {
const interaction = resolveFadeOnHoverInteraction({
cursorInsideWindow: true,
enabled: true,
transparentForFade: true,
transparentForPointer: true,
})
expect(interaction.fadeStage).toBe(false)
expect(interaction.ignoreMouseEvents).toBe(true)
})
it('keeps the stage visible and interactive when Auto Hide is disabled', () => {
const interaction = resolveFadeOnHoverInteraction({
cursorInsideWindow: true,
enabled: false,
transparentForFade: false,
transparentForPointer: false,
})
expect(interaction.fadeStage).toBe(false)
expect(interaction.ignoreMouseEvents).toBe(false)
})
})
@@ -0,0 +1,28 @@
/**
* Resolves the visual and native input behavior for Auto Hide outside interactive controls.
* A faded stage must ignore mouse events so its invisible pixels cannot block the app below.
*/
export function resolveFadeOnHoverInteraction(params: {
cursorInsideWindow: boolean
enabled: boolean
transparentForFade: boolean
transparentForPointer: boolean
}) {
const fadeStage = params.enabled
&& params.cursorInsideWindow
&& !params.transparentForFade
// NOTICE:
// Fade detection deliberately uses a sampled region to avoid flickering around model edges,
// while native pointer hit-testing uses the exact pixel to keep visible model pixels interactive.
// Once the whole stage fades, that exact pixel can still report the now-invisible model as opaque,
// so the fade decision must also enable click-through. This preserves the Auto Hide contract that
// invisible stage content cannot block the application below it.
// Source/context: `apps/stage-tamagotchi/src/renderer/pages/index.vue` transparency samplers.
// Removal condition: the visual fade no longer covers pixels considered interactive by hit-testing.
return {
fadeStage,
ignoreMouseEvents: params.enabled
&& (fadeStage || params.transparentForPointer),
}
}