fix(stage-tamagotchi): synchronize fade and click-through (#2210)
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user