feat(stage-tamagotchi): animate tray window alignment (#2319)

This commit is contained in:
Neko
2026-08-19 14:17:39 +08:00
committed by GitHub
parent 5e95e56d62
commit 2bc1c51c29
4 changed files with 173 additions and 54 deletions
@@ -1,51 +1,23 @@
import type { createContext } from '@moeru/eventa/adapters/electron/main' import type { createContext } from '@moeru/eventa/adapters/electron/main'
import type { BrowserWindow, Rectangle } from 'electron' import type { BrowserWindow } from 'electron'
import type { OnboardingWindowManager } from '../../../windows/onboarding' import type { OnboardingWindowManager } from '../../../windows/onboarding'
import { defineInvokeHandler } from '@moeru/eventa' import { defineInvokeHandler } from '@moeru/eventa'
import { animate, utils } from 'animejs'
import { screen } from 'electron' import { screen } from 'electron'
import { electronOpenOnboarding } from '../../../../shared/eventa' import { electronOpenOnboarding } from '../../../../shared/eventa'
import { Animator } from '../../../windows/shared/animator'
import { computeAdjacentPosition } from '../../../windows/shared/display' import { computeAdjacentPosition } from '../../../windows/shared/display'
const ANIMATION_DURATION = 350 const ANIMATION_DURATION = 350
function animateWindowTo(
window: BrowserWindow,
target: Rectangle,
): ReturnType<typeof animate> | undefined {
if (window.isDestroyed())
return undefined
const current = window.getBounds()
const needsResize = current.width !== target.width || current.height !== target.height
if (needsResize)
window.setSize(target.width, target.height)
const state = { x: current.x, y: current.y }
return animate(state, {
x: target.x,
y: target.y,
duration: ANIMATION_DURATION,
ease: 'outCubic',
modifier: utils.round(0),
onRender: () => {
if (!window.isDestroyed())
window.setPosition(Math.round(state.x), Math.round(state.y))
},
})
}
export function createOnboardingService(params: { export function createOnboardingService(params: {
context: ReturnType<typeof createContext>['context'] context: ReturnType<typeof createContext>['context']
onboardingWindowManager: OnboardingWindowManager onboardingWindowManager: OnboardingWindowManager
mainWindow: BrowserWindow mainWindow: BrowserWindow
}) { }) {
let currentAnimation: ReturnType<typeof animate> | undefined const mainWindowAnimator = new Animator(params.mainWindow)
let cleanupOnClosed: (() => void) | undefined let cleanupOnClosed: (() => void) | undefined
defineInvokeHandler(params.context, electronOpenOnboarding, async () => { defineInvokeHandler(params.context, electronOpenOnboarding, async () => {
@@ -61,13 +33,12 @@ export function createOnboardingService(params: {
display.workArea, display.workArea,
) )
currentAnimation?.pause() mainWindowAnimator.windowBoundsAnimateTo({
currentAnimation = animateWindowTo(params.mainWindow, {
x: adjacent.x, x: adjacent.x,
y: adjacent.y, y: adjacent.y,
width: adjacent.width, width: adjacent.width,
height: adjacent.height, height: adjacent.height,
}) }, { duration: ANIMATION_DURATION })
let userMovedManually = false let userMovedManually = false
let ignoreNextMoves = true let ignoreNextMoves = true
@@ -91,8 +62,7 @@ export function createOnboardingService(params: {
params.mainWindow.removeListener('resize', moveListener) params.mainWindow.removeListener('resize', moveListener)
if (!userMovedManually && !params.mainWindow.isDestroyed()) { if (!userMovedManually && !params.mainWindow.isDestroyed()) {
currentAnimation?.pause() mainWindowAnimator.windowBoundsAnimateTo(savedBounds, { duration: ANIMATION_DURATION })
currentAnimation = animateWindowTo(params.mainWindow, savedBounds)
} }
cleanupOnClosed = undefined cleanupOnClosed = undefined
+42 -18
View File
@@ -1,5 +1,5 @@
import type { LocaleDetector } from '@intlify/core' import type { LocaleDetector } from '@intlify/core'
import type { BrowserWindow } from 'electron' import type { BrowserWindow, Rectangle } from 'electron'
import type { I18n } from '../libs/i18n' import type { I18n } from '../libs/i18n'
import type { ServerChannel } from '../services/airi/channel-server' import type { ServerChannel } from '../services/airi/channel-server'
@@ -22,6 +22,7 @@ import macOSTrayIcon from '../../../resources/tray-icon-macos.png?asset'
import { onAppBeforeQuit } from '../libs/bootkit/lifecycle' import { onAppBeforeQuit } from '../libs/bootkit/lifecycle'
import { setupInlayWindow } from '../windows/inlay' import { setupInlayWindow } from '../windows/inlay'
import { Animator } from '../windows/shared/animator'
import { computeResizedBoundsAnchoredToDominantDisplay, findDominantDisplayArea } from '../windows/shared/display' import { computeResizedBoundsAnchoredToDominantDisplay, findDominantDisplayArea } from '../windows/shared/display'
import { toggleWindowShow } from '../windows/shared/window' import { toggleWindowShow } from '../windows/shared/window'
@@ -53,28 +54,37 @@ function applyWindowSize(window: BrowserWindow, width: number, height: number, x
window.show() window.show()
} }
function alignWindow(window: BrowserWindow, position: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'): void { function resolveAlignedWindowBounds(
window: BrowserWindow,
workArea: Rectangle,
position: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
): Rectangle {
const { width: windowWidth, height: windowHeight } = window.getBounds() const { width: windowWidth, height: windowHeight } = window.getBounds()
const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = screen.getPrimaryDisplay().workArea const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = workArea
let x = areaX
let y = areaY
switch (position) { switch (position) {
case 'center': case 'center':
window.center() x = areaX + Math.floor((areaWidth - windowWidth) / 2)
y = areaY + Math.floor((areaHeight - windowHeight) / 2)
break break
case 'top-left': case 'top-left':
window.setPosition(areaX, areaY)
break break
case 'top-right': case 'top-right':
window.setPosition(areaX + areaWidth - windowWidth, areaY) x = areaX + areaWidth - windowWidth
break break
case 'bottom-left': case 'bottom-left':
window.setPosition(areaX, areaY + areaHeight - windowHeight) y = areaY + areaHeight - windowHeight
break break
case 'bottom-right': case 'bottom-right':
window.setPosition(areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight) x = areaX + areaWidth - windowWidth
y = areaY + areaHeight - windowHeight
break break
} }
window.show()
return { x, y, width: windowWidth, height: windowHeight }
} }
function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean { function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean {
@@ -98,6 +108,19 @@ export function setupTray(params: {
i18n: I18n i18n: I18n
}): void { }): void {
once(() => { once(() => {
const mainWindowAnimator = new Animator(params.mainWindow)
function animateMainWindowTo(workArea: Rectangle, position: Parameters<typeof resolveAlignedWindowBounds>[2]) {
const bounds = resolveAlignedWindowBounds(params.mainWindow, workArea, position)
mainWindowAnimator.windowBoundsAnimateTo(bounds)
params.mainWindow.show()
}
function applyMainWindowSize(width: number, height: number, x?: number, y?: number) {
mainWindowAnimator.stop()
applyWindowSize(params.mainWindow, width, height, x, y)
}
const trayImage = nativeImage.createFromPath(isMacOS ? macOSTrayIcon : icon).resize({ width: 16 }) const trayImage = nativeImage.createFromPath(isMacOS ? macOSTrayIcon : icon).resize({ width: 16 })
trayImage.setTemplateImage(isMacOS) trayImage.setTemplateImage(isMacOS)
@@ -128,25 +151,25 @@ export function setupTray(params: {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'),
type: 'checkbox', type: 'checkbox',
checked: isSizeMatch(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT), checked: isSizeMatch(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
click: () => applyWindowSize(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT), click: () => applyMainWindowSize(RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
}, },
{ {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_height'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_height'),
type: 'checkbox', type: 'checkbox',
checked: isSizeMatch(params.mainWindow, fullWidthTarget, fullHeightTarget), checked: isSizeMatch(params.mainWindow, fullWidthTarget, fullHeightTarget),
click: () => applyWindowSize(params.mainWindow, fullWidthTarget, fullHeightTarget), click: () => applyMainWindowSize(fullWidthTarget, fullHeightTarget),
}, },
{ {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.half_height'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.half_height'),
type: 'checkbox', type: 'checkbox',
checked: isSizeMatch(params.mainWindow, halfWidthTarget, halfHeightTarget), checked: isSizeMatch(params.mainWindow, halfWidthTarget, halfHeightTarget),
click: () => applyWindowSize(params.mainWindow, halfWidthTarget, halfHeightTarget), click: () => applyMainWindowSize(halfWidthTarget, halfHeightTarget),
}, },
{ {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_screen'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_screen'),
type: 'checkbox', type: 'checkbox',
checked: isSizeMatch(params.mainWindow, areaWidth, areaHeight), checked: isSizeMatch(params.mainWindow, areaWidth, areaHeight),
click: () => applyWindowSize(params.mainWindow, areaWidth, areaHeight, areaX, areaY), click: () => applyMainWindowSize(areaWidth, areaHeight, areaX, areaY),
}, },
], ],
}, },
@@ -157,32 +180,32 @@ export function setupTray(params: {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.center'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.center'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX + Math.floor((areaWidth - windowWidth) / 2), areaY + Math.floor((areaHeight - windowHeight) / 2)), checked: isPositionMatch(params.mainWindow, areaX + Math.floor((areaWidth - windowWidth) / 2), areaY + Math.floor((areaHeight - windowHeight) / 2)),
click: () => alignWindow(params.mainWindow, 'center'), click: () => animateMainWindowTo(currentDisplay.workArea, 'center'),
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX, areaY), checked: isPositionMatch(params.mainWindow, areaX, areaY),
click: () => alignWindow(params.mainWindow, 'top-left'), click: () => animateMainWindowTo(currentDisplay.workArea, 'top-left'),
}, },
{ {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_right'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_right'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY), checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY),
click: () => alignWindow(params.mainWindow, 'top-right'), click: () => animateMainWindowTo(currentDisplay.workArea, 'top-right'),
}, },
{ {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_left'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_left'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX, areaY + areaHeight - windowHeight), checked: isPositionMatch(params.mainWindow, areaX, areaY + areaHeight - windowHeight),
click: () => alignWindow(params.mainWindow, 'bottom-left'), click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-left'),
}, },
{ {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_right'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_right'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight), checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight),
click: () => alignWindow(params.mainWindow, 'bottom-right'), click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-right'),
}, },
], ],
}, },
@@ -244,6 +267,7 @@ export function setupTray(params: {
stopLocaleEffect() stopLocaleEffect()
rebuildContextMenu.cancel() rebuildContextMenu.cancel()
mainWindowAnimator.stop()
appTray.destroy() appTray.destroy()
}) })
@@ -0,0 +1,70 @@
import type { BrowserWindow, Rectangle } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import { Animator } from './animator'
function createWindow(initialBounds: Rectangle) {
const bounds = { ...initialBounds }
return {
getBounds: vi.fn(() => ({ ...bounds })),
isDestroyed: vi.fn(() => false),
setPosition: vi.fn((x: number, y: number) => {
bounds.x = x
bounds.y = y
}),
setSize: vi.fn((width: number, height: number) => {
bounds.width = width
bounds.height = height
}),
} satisfies Pick<BrowserWindow, 'getBounds' | 'isDestroyed' | 'setPosition' | 'setSize'>
}
function waitForAnimation(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 30))
}
describe('window bounds animator', () => {
it('animates position after it applies the target size', async () => {
const window = createWindow({ x: 10, y: 20, width: 300, height: 400 })
const animator = new Animator(window)
animator.windowBoundsAnimateTo(
{ x: 100, y: 200, width: 450, height: 600 },
{ duration: 1 },
)
await waitForAnimation()
expect(window.setSize).toHaveBeenCalledWith(450, 600)
expect(window.setPosition).toHaveBeenLastCalledWith(100, 200)
})
it('stops the previous animation before it starts a new animation', async () => {
const window = createWindow({ x: 10, y: 20, width: 300, height: 400 })
const animator = new Animator(window)
animator.windowBoundsAnimateTo(
{ x: 100, y: 100, width: 300, height: 400 },
{ duration: 100 },
)
animator.windowBoundsAnimateTo(
{ x: 200, y: 200, width: 300, height: 400 },
{ duration: 1 },
)
await waitForAnimation()
expect(window.setPosition).toHaveBeenLastCalledWith(200, 200)
})
it('does not start an animation for a destroyed window', () => {
const window = createWindow({ x: 10, y: 20, width: 300, height: 400 })
window.isDestroyed.mockReturnValue(true)
const animator = new Animator(window)
animator.windowBoundsAnimateTo({ x: 100, y: 200, width: 300, height: 400 })
expect(window.setPosition).not.toHaveBeenCalled()
expect(window.setSize).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,55 @@
import type { BrowserWindow, Rectangle } from 'electron'
import { animate, utils } from 'animejs'
type AnimatableWindow = Pick<BrowserWindow, 'getBounds' | 'isDestroyed' | 'setPosition' | 'setSize'>
/** Options for one window bounds animation. */
export interface WindowBoundsAnimationOptions {
/** Animation duration in milliseconds. @default 350 */
duration?: number
}
/**
* Owns the active position animation for one Electron window.
*
* A new animation stops the previous animation. The class applies the target
* size before movement. It does not animate window resizing.
*/
export class Animator {
private animation?: ReturnType<typeof animate>
constructor(private readonly window: AnimatableWindow) {}
/** Animates the window position to the target bounds. */
windowBoundsAnimateTo(target: Rectangle, options: WindowBoundsAnimationOptions = {}): void {
this.stop()
if (this.window.isDestroyed())
return
const current = this.window.getBounds()
if (current.width !== target.width || current.height !== target.height)
this.window.setSize(target.width, target.height)
const state = { x: current.x, y: current.y }
this.animation = animate(state, {
x: target.x,
y: target.y,
duration: options.duration ?? 350,
ease: 'outCubic',
modifier: utils.round(0),
onRender: () => {
if (!this.window.isDestroyed())
this.window.setPosition(Math.round(state.x), Math.round(state.y))
},
})
}
/** Stops the active animation. */
stop(): void {
this.animation?.pause()
this.animation = undefined
}
}