feat(glass): refine material response and rendering lifecycle (#596)

This commit is contained in:
InfinityPacer
2026-07-29 11:19:12 +08:00
committed by GitHub
parent 90d626e7c6
commit 287cbc76ae
29 changed files with 4145 additions and 632 deletions
+89 -21
View File
@@ -11,10 +11,15 @@ import {
GLASS_OPTICAL_STRENGTH_MAX,
GLASS_OPTICAL_STRENGTH_MIN,
getAvailableGlassOpticalPresets,
getGlassOpticalPresetKey,
getGlassOpticalPresetParameters,
getGlassOpticalPresetParametersWithOverrides,
normalizeGlassOpticalStrength,
type GlassOpticalParameters,
type GlassOpticalPreset,
type GlassOpticalPresetOverrides,
} from '@/utils/glassOptics'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
@@ -32,11 +37,13 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const display = useDisplay()
const { settings } = useThemeCustomizer()
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
const draftFlowStrength = ref(settings.value.glassFlowStrength)
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
@@ -68,6 +75,7 @@ watch(
draftDeformationStrength.value = settings.value.glassDeformationStrength
draftFlowStrength.value = settings.value.glassFlowStrength
draftPreset.value = settings.value.glassPreset
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
draftQuality.value = settings.value.glassQuality
draftReflectionStrength.value = settings.value.glassReflectionStrength
draftTransmissionStrength.value = settings.value.glassTransmissionStrength
@@ -112,7 +120,7 @@ function updateAppearance(value: unknown) {
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
draftAppearance.value = value
previewGlassSettings({ glassAppearance: value })
applyPreset(activePreset.value)
}
/** 仅允许面板声明的质量档位进入待保存设置。 */
@@ -121,15 +129,18 @@ function updateQuality(value: unknown) {
if (!option) return
draftQuality.value = option.value
previewGlassSettings({ glassQuality: option.value })
applyPreset(activePreset.value)
}
/** 将六个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
/** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */
function previewDraftParameters() {
previewGlassSettings({
glassAppearance: draftAppearance.value,
glassDeformationStrength: draftDeformationStrength.value,
glassFlowStrength: draftFlowStrength.value,
glassPreset: draftPreset.value,
glassPresetOverrides: draftPresetOverrides.value,
glassQuality: draftQuality.value,
glassReflectionStrength: draftReflectionStrength.value,
glassTransmissionStrength: draftTransmissionStrength.value,
glassTranslationStrength: draftTranslationStrength.value,
@@ -137,13 +148,29 @@ function previewDraftParameters() {
})
}
/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */
/** 读取草稿中当前显示的六参数。 */
function getDraftParameters(): GlassOpticalParameters {
return {
deformation: draftDeformationStrength.value,
flow: draftFlowStrength.value,
reflection: draftReflectionStrength.value,
transmission: draftTransmissionStrength.value,
translation: draftTranslationStrength.value,
transparency: draftTransparencyStrength.value,
}
}
/** 切换方案时优先恢复该组合的草稿覆盖,没有覆盖才使用矩阵。 */
function applyPreset(value: unknown) {
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
if (!availablePresets.value.includes(value)) return
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value)
draftPreset.value = value
const effectivePreset = availablePresets.value.includes(value) ? value : 'natural'
const parameters = getGlassOpticalPresetParametersWithOverrides(
draftAppearance.value,
draftQuality.value,
effectivePreset,
draftPresetOverrides.value,
)
draftPreset.value = effectivePreset
draftDeformationStrength.value = parameters.deformation
draftFlowStrength.value = parameters.flow
draftReflectionStrength.value = parameters.reflection
@@ -153,45 +180,66 @@ function applyPreset(value: unknown) {
previewDraftParameters()
}
/** 用调整后的当前六参数覆盖当前材质、质量与方案组合。 */
function updateDraftPresetOverride() {
const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
draftPresetOverrides.value = {
...draftPresetOverrides.value,
[key]: getDraftParameters(),
}
previewDraftParameters()
}
/** 将采样平移限制为 renderer 支持的稳定范围。 */
function updateTranslationStrength(value: unknown) {
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value })
updateDraftPresetOverride()
}
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
function updateDeformationStrength(value: unknown) {
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value })
updateDraftPresetOverride()
}
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
function updateFlowStrength(value: unknown) {
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value })
updateDraftPresetOverride()
}
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
function updateReflectionStrength(value: unknown) {
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
updateDraftPresetOverride()
}
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
function updateTransmissionStrength(value: unknown) {
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value })
updateDraftPresetOverride()
}
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
function updateTransparencyStrength(value: unknown) {
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value })
updateDraftPresetOverride()
}
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */
/** 删除当前组合覆盖并恢复该方案矩阵,不影响其他组合。 */
function resetSettings() {
applyPreset(activePreset.value)
const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
const nextOverrides = { ...draftPresetOverrides.value }
delete nextOverrides[key]
draftPresetOverrides.value = nextOverrides
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, draftPreset.value)
draftDeformationStrength.value = parameters.deformation
draftFlowStrength.value = parameters.flow
draftReflectionStrength.value = parameters.reflection
draftTransmissionStrength.value = parameters.transmission
draftTranslationStrength.value = parameters.translation
draftTransparencyStrength.value = parameters.transparency
previewDraftParameters()
}
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
@@ -206,6 +254,7 @@ async function saveSettings() {
glassDeformationStrength: draftDeformationStrength.value,
glassFlowStrength: draftFlowStrength.value,
glassPreset: draftPreset.value,
glassPresetOverrides: draftPresetOverrides.value,
glassQuality: draftQuality.value,
glassReflectionStrength: draftReflectionStrength.value,
glassTransmissionStrength: draftTransmissionStrength.value,
@@ -224,7 +273,14 @@ onScopeDispose(cancelGlassPreview)
</script>
<template>
<VDialog v-if="visible" v-model="visible" width="100%" max-width="30rem" scrollable>
<VDialog
v-if="visible"
v-model="visible"
width="100%"
max-width="30rem"
scrollable
:fullscreen="display.smAndDown.value"
>
<VCard>
<VCardItem>
<VCardTitle>
@@ -415,14 +471,21 @@ onScopeDispose(cancelGlassPreview)
</VCardText>
<VDivider />
<VCardText class="text-center">
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
<VCardActions class="glass-settings-dialog__actions justify-center">
<VBtn :slim="false" variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
{{ t('common.reset') }}
</VBtn>
<VBtn color="primary" prepend-icon="mdi-content-save" :loading="isSaving" @click="saveSettings">
<VBtn
:slim="false"
color="primary"
variant="elevated"
prepend-icon="mdi-content-save"
:loading="isSaving"
@click="saveSettings"
>
{{ t('common.save') }}
</VBtn>
</VCardText>
</VCardActions>
</VCard>
</VDialog>
</template>
@@ -436,6 +499,11 @@ onScopeDispose(cancelGlassPreview)
padding: 20px 24px 24px;
}
.glass-settings-dialog__actions {
flex: none;
padding: 16px 24px;
}
.glass-settings-dialog__label {
margin: 0 0 10px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
+2 -7
View File
@@ -173,12 +173,7 @@ onBeforeMount(async () => {
<template>
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard
v-if="renderMode === 'vuetify'"
:title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"
data-glass-optical-surface
data-glass-optical-mode="static-material"
>
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
@@ -213,7 +208,7 @@ onBeforeMount(async () => {
</VCardActions>
</VCard>
<!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
<VCard v-else-if="renderMode === 'vue'">
<VCardText class="pa-0">
<component
:is="dynamicComponent"
+2 -7
View File
@@ -136,12 +136,7 @@ onMounted(() => {
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard
v-if="renderMode === 'vuetify'"
:title="`${props.plugin?.plugin_name}`"
data-glass-optical-surface
data-glass-optical-mode="static-material"
>
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
<VDialogCloseBtn @click="emit('close')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else class="min-h-40">
@@ -163,7 +158,7 @@ onMounted(() => {
/>
</VCard>
<!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
<VCard v-else-if="renderMode === 'vue'">
<VCardText class="pa-0">
<component
:is="dynamicComponent"
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
const slotStub = { template: '<div><slot /></div>' }
const dialogStub = {
props: ['fullscreen'],
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
}
const toggleStub = {
props: ['modelValue'],
template: '<div :data-model-value="modelValue"><slot /></div>',
@@ -25,6 +29,7 @@ const mocks = vi.hoisted(() => ({
glassDeformationStrength: 50,
glassFlowStrength: 50,
glassPreset: 'natural',
glassPresetOverrides: {},
glassQuality: 'css',
glassReflectionStrength: 50,
glassTransmissionStrength: 50,
@@ -47,6 +52,10 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vuetify', () => ({
useDisplay: () => ({ smAndDown: { value: true } }),
}))
describe('GlassSettingsDialog', () => {
beforeEach(() => {
mocks.cancelGlassPreview.mockClear()
@@ -56,6 +65,7 @@ describe('GlassSettingsDialog', () => {
mocks.settings.value.glassDeformationStrength = 50
mocks.settings.value.glassFlowStrength = 50
mocks.settings.value.glassPreset = 'natural'
mocks.settings.value.glassPresetOverrides = {}
mocks.settings.value.glassQuality = 'css'
mocks.settings.value.glassReflectionStrength = 50
mocks.settings.value.glassTransmissionStrength = 50
@@ -68,8 +78,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -81,6 +92,8 @@ describe('GlassSettingsDialog', () => {
expect(sliders).toHaveLength(3)
expect(sliders[0].attributes('data-disabled')).toBe('undefined')
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
expect(wrapper.find('.dialog-stub').attributes('data-fullscreen')).toBe('true')
expect(wrapper.find('.glass-settings-dialog__actions').classes()).toContain('justify-center')
await wrapper.setProps({ modelValue: false })
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
@@ -96,17 +109,37 @@ describe('GlassSettingsDialog', () => {
mocks.settings.value.glassTransmissionStrength = 57
mocks.settings.value.glassTranslationStrength = 57
mocks.settings.value.glassTransparencyStrength = 55
mocks.settings.value.glassPresetOverrides = {
'clear:balanced:glide': {
deformation: 24,
flow: 35,
reflection: 46,
transmission: 57,
translation: 68,
transparency: 79,
},
'frosted:high:liquid': {
deformation: 65,
flow: 61,
reflection: 58,
transmission: 57,
translation: 57,
transparency: 55,
},
}
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VBtn: {
emits: ['click'],
props: ['prependIcon'],
template: '<button :data-icon="prependIcon" @click="$emit(\'click\')"><slot /></button>',
props: ['prependIcon', 'slim', 'variant'],
template:
'<button :data-icon="prependIcon" :data-slim="String(slim)" :data-variant="variant" @click="$emit(\'click\')"><slot /></button>',
},
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VDivider: true,
VSlider: sliderStub,
@@ -115,17 +148,33 @@ describe('GlassSettingsDialog', () => {
props: { modelValue: true },
})
const resetButton = wrapper.find('[data-icon="mdi-refresh"]')
const saveButton = wrapper.find('[data-icon="mdi-content-save"]')
expect(resetButton.attributes('data-slim')).toBe('false')
expect(saveButton.attributes('data-slim')).toBe('false')
expect(saveButton.attributes('data-variant')).toBe('elevated')
await resetButton.trigger('click')
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
glassDeformationStrength: 82,
glassFlowStrength: 80,
glassAppearance: 'frosted',
glassDeformationStrength: 66,
glassFlowStrength: 64,
glassPreset: 'liquid',
glassPresetOverrides: {
'clear:balanced:glide': {
deformation: 24,
flow: 35,
reflection: 46,
transmission: 57,
translation: 68,
transparency: 79,
},
},
glassQuality: 'high',
glassReflectionStrength: 31,
glassTransmissionStrength: 58,
glassTranslationStrength: 54,
glassTransparencyStrength: 46,
glassTransmissionStrength: 47,
glassTranslationStrength: 43,
glassTransparencyStrength: 32,
})
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
})
@@ -135,8 +184,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -149,7 +199,27 @@ describe('GlassSettingsDialog', () => {
expect(sliders[2].attributes('data-disabled')).toBe('undefined')
await sliders[2].setValue('86')
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassReflectionStrength: 86 })
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
glassAppearance: 'clear',
glassDeformationStrength: 50,
glassFlowStrength: 50,
glassPreset: 'natural',
glassPresetOverrides: {
'clear:css:natural': {
deformation: 50,
flow: 50,
reflection: 86,
transmission: 50,
translation: 50,
transparency: 50,
},
},
glassQuality: 'css',
glassReflectionStrength: 86,
glassTransmissionStrength: 50,
glassTranslationStrength: 50,
glassTransparencyStrength: 50,
})
})
it('hides preset choices in standard quality', () => {
@@ -157,8 +227,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -170,7 +241,7 @@ describe('GlassSettingsDialog', () => {
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
})
it('keeps the selected preset highlighted after slider adjustments', async () => {
it('keeps the selected preset highlighted and records its combination override', async () => {
mocks.settings.value.glassQuality = 'balanced'
mocks.settings.value.glassPreset = 'glide'
mocks.settings.value.glassTransparencyStrength = 61
@@ -178,10 +249,11 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VBtn: slotStub,
VBtnToggle: toggleStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -199,6 +271,21 @@ describe('GlassSettingsDialog', () => {
await sliders[0].setValue('77')
expect(preset.attributes('data-model-value')).toBe('glide')
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith(
expect.objectContaining({
glassPreset: 'glide',
glassPresetOverrides: {
'clear:balanced:glide': {
deformation: 50,
flow: 50,
reflection: 50,
transmission: 50,
translation: 50,
transparency: 77,
},
},
}),
)
})
it('normalizes and previews all six independent slider values', async () => {
@@ -207,8 +294,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
@@ -227,12 +315,28 @@ describe('GlassSettingsDialog', () => {
await sliders[4].setValue('68.7')
await sliders[5].setValue('62.2')
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassTransmissionStrength: 78 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassReflectionStrength: 74 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassTranslationStrength: 84 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassDeformationStrength: 69 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(6, { glassFlowStrength: 62 })
expect(mocks.previewGlassSettings).toHaveBeenCalledTimes(6)
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
glassAppearance: 'clear',
glassDeformationStrength: 69,
glassFlowStrength: 62,
glassPreset: 'natural',
glassPresetOverrides: {
'clear:balanced:natural': {
deformation: 69,
flow: 62,
reflection: 74,
transmission: 78,
translation: 84,
transparency: 91,
},
},
glassQuality: 'balanced',
glassReflectionStrength: 74,
glassTransmissionStrength: 78,
glassTranslationStrength: 84,
glassTransparencyStrength: 91,
})
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
})
@@ -242,8 +346,9 @@ describe('GlassSettingsDialog', () => {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
+172 -7
View File
@@ -2,6 +2,8 @@
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import {
createGlassWallpaperSourceCache,
getGlassWallpaperPreparationKey,
setGlassRendererState,
useGlassOpticalInteractionSource,
useGlassOpticalRenderer,
@@ -39,16 +41,40 @@ const props = defineProps<{
previousWallpaperUrl: string
/** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */
pendingWallpaperUrl?: string
/** 单调递增的壁纸准备事务版本;相同 URL 的旧回执不得完成新事务。 */
pendingWallpaperRevision?: number
/** 父层已完成可见图片预载,允许两个 context 在同一绘制帧提交该 revision。 */
activateWallpaperRevision?: number
}>()
const timingWindow = window as typeof window & {
__glassPerformanceProbeEnabled?: boolean
__glassLaunchTimings?: Array<{ detail?: string; stage: string; time: number }>
}
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
timingWindow.__glassLaunchTimings ??= []
timingWindow.__glassLaunchTimings.push({
stage: 'optical-layer-setup',
time: performance.now(),
})
}
const emit = defineEmits<{
/** fixed 与 scroll renderer 均已准备同一张待切换纹理。 */
wallpaperPrepared: [url: string]
wallpaperPrepared: [url: string, revision: number]
/** 任一 context 无法准备当前 revision;父层必须立即取消整笔事务。 */
wallpaperPreparationFailed: [url: string, revision: number]
/** fixed 与 scroll renderer 均已消费 prepared 纹理并切到活动槽。 */
wallpaperActivated: [url: string, revision: number, startedAt: number]
/** 任一 context 提交失败;父层必须取消该 revision。 */
wallpaperActivationFailed: [url: string, revision: number]
}>()
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
const interactionSource = useGlassOpticalInteractionSource()
const pagePresentationMotion = usePagePresentationMotion()
const wallpaperSourceCache = createGlassWallpaperSourceCache()
const fixedRenderer = useGlassOpticalRenderer({
active: true,
appearance: () => props.appearance,
@@ -66,8 +92,10 @@ const fixedRenderer = useGlassOpticalRenderer({
transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
wallpaperSourceCache,
previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
surfaceSpace: 'fixed',
syncDocumentState: false,
})
@@ -89,8 +117,10 @@ const scrollRenderer = useGlassOpticalRenderer({
transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
wallpaperSourceCache,
previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
surfaceSpace: 'scroll',
syncDocumentState: false,
})
@@ -107,16 +137,151 @@ watchEffect(() => {
: 'fallback'
setGlassRendererState(rendererState, state)
})
watchEffect(() => {
const url = props.pendingWallpaperUrl
if (url && fixedRenderer.preparedWallpaperUrl.value === url && scrollRenderer.preparedWallpaperUrl.value === url) {
emit('wallpaperPrepared', url)
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
timingWindow.__glassLaunchTimings?.push({
detail: state,
stage: 'optical-layer-state',
time: performance.now(),
})
}
})
let lastPreparedAcknowledgement = ''
watchEffect(() => {
const url = props.pendingWallpaperUrl
const revision = props.pendingWallpaperRevision ?? 0
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url ?? '')
const acknowledgement = `${revision}:${preparationKey}:${url}`
const prepared =
Boolean(url) &&
revision > 0 &&
fixedRenderer.state.value === 'ready' &&
scrollRenderer.state.value === 'ready' &&
fixedRenderer.preparedWallpaperUrl.value === url &&
fixedRenderer.preparedWallpaperRevision.value === revision &&
fixedRenderer.preparedWallpaperPreparationKey.value === preparationKey &&
scrollRenderer.preparedWallpaperUrl.value === url &&
scrollRenderer.preparedWallpaperRevision.value === revision &&
scrollRenderer.preparedWallpaperPreparationKey.value === preparationKey
if (prepared && acknowledgement !== lastPreparedAcknowledgement) {
lastPreparedAcknowledgement = acknowledgement
emit('wallpaperPrepared', url, revision)
}
})
let lastPreparationFailedAcknowledgement = ''
watchEffect(() => {
const url = props.pendingWallpaperUrl ?? ''
const revision = props.pendingWallpaperRevision ?? 0
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url)
const acknowledgement = `${revision}:${preparationKey}:${url}`
const failed = [fixedRenderer, scrollRenderer].some(
renderer =>
renderer.failedWallpaperUrl.value === url &&
renderer.failedWallpaperRevision.value === revision &&
renderer.failedWallpaperPreparationKey.value === preparationKey,
)
if (url && revision > 0 && failed && acknowledgement !== lastPreparationFailedAcknowledgement) {
lastPreparationFailedAcknowledgement = acknowledgement
emit('wallpaperPreparationFailed', url, revision)
}
})
let activationFrame: number | null = null
let scheduledActivation = ''
let lastActivatedAcknowledgement = ''
let lastFailedAcknowledgement = ''
function rollbackWallpaperActivation(url: string, revision: number) {
for (const renderer of [fixedRenderer, scrollRenderer]) {
try {
renderer.rollbackPreparedWallpaperActivation(url, revision)
} catch {
// 两个 context 独立回滚;一个异常不得阻止另一个恢复并通知父层取消。
}
}
}
watchEffect(() => {
const url = props.pendingWallpaperUrl
const revision = props.pendingWallpaperRevision ?? 0
const activationRevision = props.activateWallpaperRevision ?? 0
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url ?? '')
const acknowledgement = `${revision}:${preparationKey}:${url}`
const canActivate =
Boolean(url) &&
revision > 0 &&
activationRevision === revision &&
fixedRenderer.state.value === 'ready' &&
scrollRenderer.state.value === 'ready' &&
fixedRenderer.preparedWallpaperUrl.value === url &&
fixedRenderer.preparedWallpaperRevision.value === revision &&
fixedRenderer.preparedWallpaperPreparationKey.value === preparationKey &&
scrollRenderer.preparedWallpaperUrl.value === url &&
scrollRenderer.preparedWallpaperRevision.value === revision &&
scrollRenderer.preparedWallpaperPreparationKey.value === preparationKey
if (
!canActivate ||
acknowledgement === lastActivatedAcknowledgement ||
acknowledgement === lastFailedAcknowledgement ||
acknowledgement === scheduledActivation
) {
return
}
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
scheduledActivation = acknowledgement
activationFrame = requestAnimationFrame(startedAt => {
activationFrame = null
scheduledActivation = ''
const currentUrl = props.pendingWallpaperUrl ?? ''
const currentRevision = props.pendingWallpaperRevision ?? 0
const currentPreparationKey = getGlassWallpaperPreparationKey(
props.appearance,
props.quality,
props.routeKey,
currentUrl,
)
const currentAcknowledgement = `${currentRevision}:${currentPreparationKey}:${currentUrl}`
if (
currentAcknowledgement !== acknowledgement ||
props.activateWallpaperRevision !== currentRevision ||
!fixedRenderer.canActivatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey) ||
!scrollRenderer.canActivatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey)
) {
return
}
try {
const fixedActivated = fixedRenderer.activatePreparedWallpaper(
currentUrl,
currentRevision,
currentPreparationKey,
startedAt,
)
const scrollActivated =
fixedActivated &&
scrollRenderer.activatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey, startedAt)
if (!fixedActivated || !scrollActivated) {
rollbackWallpaperActivation(currentUrl, currentRevision)
lastFailedAcknowledgement = acknowledgement
emit('wallpaperActivationFailed', currentUrl, currentRevision)
return
}
} catch {
rollbackWallpaperActivation(currentUrl, currentRevision)
lastFailedAcknowledgement = acknowledgement
emit('wallpaperActivationFailed', currentUrl, currentRevision)
return
}
lastActivatedAcknowledgement = acknowledgement
emit('wallpaperActivated', currentUrl, currentRevision, startedAt)
})
})
onScopeDispose(() => {
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
setGlassRendererState(rendererState, 'fallback')
})
</script>
@@ -1,10 +1,30 @@
import { shallowMount } from '@vue/test-utils'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
const rendererResults = vi.hoisted(
() =>
[] as Array<{
activatePreparedWallpaper: ReturnType<typeof vi.fn>
activeWallpaperPreparationKey: { value: string }
activeWallpaperRevision: { value: number }
activeWallpaperUrl: { value: string }
canActivatePreparedWallpaper: ReturnType<typeof vi.fn>
failedWallpaperPreparationKey: { value: string }
failedWallpaperRevision: { value: number }
failedWallpaperUrl: { value: string }
preparedWallpaperPreparationKey: { value: string }
preparedWallpaperRevision: { value: number }
preparedWallpaperUrl: { value: string }
renderedFrames: { value: number }
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
state: { value: string }
}>,
)
const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() }))
const wallpaperSourceCache = vi.hoisted(() => ({ get: vi.fn() }))
const setRendererState = vi.hoisted(() =>
vi.fn((state: { value: string }, value: string) => {
state.value = value
@@ -12,21 +32,82 @@ const setRendererState = vi.hoisted(() =>
)
vi.mock('@/composables/useGlassOpticalRenderer', () => ({
createGlassWallpaperSourceCache: vi.fn(() => wallpaperSourceCache),
getGlassWallpaperPreparationKey: vi.fn(
(appearance: string, quality: string, routeKey: string, url: string) =>
`${appearance}:${quality}:${routeKey}:${url}`,
),
setGlassRendererState: setRendererState,
useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
rendererCalls.push(options)
let rollbackState = {
preparationKey: '',
revision: 0,
url: '',
}
return {
const result = {
activeWallpaperPreparationKey: ref(''),
activeWallpaperRevision: ref(0),
activeWallpaperUrl: ref(''),
failedWallpaperPreparationKey: ref(''),
failedWallpaperRevision: ref(0),
failedWallpaperUrl: ref(''),
preparedWallpaperPreparationKey: ref(''),
preparedWallpaperRevision: ref(0),
preparedWallpaperUrl: ref(''),
renderedFrames: ref(0),
state: ref('ready'),
canActivatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
return (
result.state.value === 'ready' &&
result.preparedWallpaperUrl.value === url &&
result.preparedWallpaperRevision.value === revision &&
result.preparedWallpaperPreparationKey.value === preparationKey
)
}),
activatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
if (!result.canActivatePreparedWallpaper(url, revision, preparationKey)) return false
rollbackState = {
preparationKey: result.activeWallpaperPreparationKey.value,
revision: result.activeWallpaperRevision.value,
url: result.activeWallpaperUrl.value,
}
result.preparedWallpaperUrl.value = ''
result.preparedWallpaperRevision.value = 0
result.preparedWallpaperPreparationKey.value = ''
result.activeWallpaperUrl.value = url
result.activeWallpaperRevision.value = revision
result.activeWallpaperPreparationKey.value = preparationKey
return true
}),
rollbackPreparedWallpaperActivation: vi.fn((url: string, revision: number) => {
if (result.activeWallpaperUrl.value !== url || result.activeWallpaperRevision.value !== revision) return false
result.activeWallpaperUrl.value = rollbackState.url
result.activeWallpaperRevision.value = rollbackState.revision
result.activeWallpaperPreparationKey.value = rollbackState.preparationKey
return true
}),
}
rendererResults.push(result)
return result
}),
}))
afterEach(() => {
vi.unstubAllGlobals()
})
describe('GlassOpticalLayer', () => {
it('uses exactly two visible presentation contexts with one interaction source', () => {
rendererCalls.length = 0
rendererResults.length = 0
setRendererState.mockClear()
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
@@ -52,6 +133,7 @@ describe('GlassOpticalLayer', () => {
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
expect(rendererCalls[0].pageMotion).toBeUndefined()
expect(rendererCalls[1].pageMotion).toEqual(
@@ -64,4 +146,241 @@ describe('GlassOpticalLayer', () => {
wrapper.unmount()
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
})
it('activates matching resource bundles in one shared animation frame', async () => {
rendererCalls.length = 0
rendererResults.length = 0
let activationCallback: FrameRequestCallback | null = null
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
activationCallback = callback
return 31
})
vi.stubGlobal('requestAnimationFrame', requestFrame)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
activateWallpaperRevision: 0,
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 7,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
fixedRenderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
fixedRenderer.preparedWallpaperRevision.value = 7
fixedRenderer.preparedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPrepared')).toBeUndefined()
scrollRenderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
scrollRenderer.preparedWallpaperRevision.value = 7
scrollRenderer.preparedWallpaperPreparationKey.value = 'stale-key'
await nextTick()
expect(wrapper.emitted('wallpaperPrepared')).toBeUndefined()
scrollRenderer.preparedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPrepared')).toEqual([['/wallpaper-next.jpg', 7]])
await wrapper.setProps({ activateWallpaperRevision: 7 })
await nextTick()
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
expect(requestFrame).toHaveBeenCalledOnce()
;(activationCallback as FrameRequestCallback | null)?.(420)
await nextTick()
expect(fixedRenderer.activatePreparedWallpaper).toHaveBeenCalledWith('/wallpaper-next.jpg', 7, preparationKey, 420)
expect(scrollRenderer.activatePreparedWallpaper).toHaveBeenCalledWith('/wallpaper-next.jpg', 7, preparationKey, 420)
expect(wrapper.emitted('wallpaperActivated')).toEqual([['/wallpaper-next.jpg', 7, 420]])
expect(fixedRenderer.activeWallpaperRevision.value).toBe(7)
expect(scrollRenderer.activeWallpaperRevision.value).toBe(7)
wrapper.unmount()
})
it('reports one matching preparation failure so the parent can cancel the pending revision', async () => {
rendererCalls.length = 0
rendererResults.length = 0
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 10,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
fixedRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
fixedRenderer.failedWallpaperRevision.value = 9
fixedRenderer.failedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPreparationFailed')).toBeUndefined()
scrollRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
scrollRenderer.failedWallpaperRevision.value = 10
scrollRenderer.failedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPreparationFailed')).toEqual([['/wallpaper-next.jpg', 10]])
fixedRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
fixedRenderer.failedWallpaperRevision.value = 10
fixedRenderer.failedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPreparationFailed')).toEqual([['/wallpaper-next.jpg', 10]])
wrapper.unmount()
})
it('does not partially commit while either context is unavailable', async () => {
rendererCalls.length = 0
rendererResults.length = 0
let activationCallback: FrameRequestCallback | null = null
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
activationCallback = callback
return 32
})
vi.stubGlobal('requestAnimationFrame', requestFrame)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
activateWallpaperRevision: 8,
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 8,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'high',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:high:/dashboard:/wallpaper-next.jpg'
for (const renderer of rendererResults) {
renderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
renderer.preparedWallpaperRevision.value = 8
renderer.preparedWallpaperPreparationKey.value = preparationKey
}
scrollRenderer.state.value = 'fallback'
await nextTick()
expect(requestFrame).not.toHaveBeenCalled()
expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
scrollRenderer.state.value = 'ready'
await nextTick()
expect(requestFrame).toHaveBeenCalledOnce()
;(activationCallback as FrameRequestCallback | null)?.(640)
await nextTick()
expect(fixedRenderer.activatePreparedWallpaper).toHaveBeenCalledOnce()
expect(scrollRenderer.activatePreparedWallpaper).toHaveBeenCalledOnce()
expect(wrapper.emitted('wallpaperActivated')).toEqual([['/wallpaper-next.jpg', 8, 640]])
wrapper.unmount()
})
it.each([
[
'returns false',
(renderer: (typeof rendererResults)[number]) => renderer.activatePreparedWallpaper.mockReturnValueOnce(false),
],
[
'throws',
(renderer: (typeof rendererResults)[number]) =>
renderer.activatePreparedWallpaper.mockImplementationOnce(() => {
throw new Error('context commit failed')
}),
],
])('rolls both contexts back when the second activation %s', async (_, failScrollActivation) => {
rendererCalls.length = 0
rendererResults.length = 0
let activationCallback: FrameRequestCallback | null = null
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
activationCallback = callback
return 33
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
activateWallpaperRevision: 9,
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 9,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
for (const renderer of rendererResults) {
renderer.activeWallpaperUrl.value = '/wallpaper-current.jpg'
renderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
renderer.preparedWallpaperRevision.value = 9
renderer.preparedWallpaperPreparationKey.value = preparationKey
}
failScrollActivation(scrollRenderer)
await nextTick()
;(activationCallback as FrameRequestCallback | null)?.(720)
await nextTick()
expect(fixedRenderer.rollbackPreparedWallpaperActivation).toHaveBeenCalledWith('/wallpaper-next.jpg', 9)
expect(scrollRenderer.rollbackPreparedWallpaperActivation).toHaveBeenCalledWith('/wallpaper-next.jpg', 9)
expect(fixedRenderer.activeWallpaperUrl.value).toBe('/wallpaper-current.jpg')
expect(scrollRenderer.activeWallpaperUrl.value).toBe('/wallpaper-current.jpg')
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
expect(wrapper.emitted('wallpaperActivationFailed')).toEqual([['/wallpaper-next.jpg', 9]])
wrapper.unmount()
})
})