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 { BrowserWindow, Rectangle } from 'electron'
import type { BrowserWindow } from 'electron'
import type { OnboardingWindowManager } from '../../../windows/onboarding'
import { defineInvokeHandler } from '@moeru/eventa'
import { animate, utils } from 'animejs'
import { screen } from 'electron'
import { electronOpenOnboarding } from '../../../../shared/eventa'
import { Animator } from '../../../windows/shared/animator'
import { computeAdjacentPosition } from '../../../windows/shared/display'
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: {
context: ReturnType<typeof createContext>['context']
onboardingWindowManager: OnboardingWindowManager
mainWindow: BrowserWindow
}) {
let currentAnimation: ReturnType<typeof animate> | undefined
const mainWindowAnimator = new Animator(params.mainWindow)
let cleanupOnClosed: (() => void) | undefined
defineInvokeHandler(params.context, electronOpenOnboarding, async () => {
@@ -61,13 +33,12 @@ export function createOnboardingService(params: {
display.workArea,
)
currentAnimation?.pause()
currentAnimation = animateWindowTo(params.mainWindow, {
mainWindowAnimator.windowBoundsAnimateTo({
x: adjacent.x,
y: adjacent.y,
width: adjacent.width,
height: adjacent.height,
})
}, { duration: ANIMATION_DURATION })
let userMovedManually = false
let ignoreNextMoves = true
@@ -91,8 +62,7 @@ export function createOnboardingService(params: {
params.mainWindow.removeListener('resize', moveListener)
if (!userMovedManually && !params.mainWindow.isDestroyed()) {
currentAnimation?.pause()
currentAnimation = animateWindowTo(params.mainWindow, savedBounds)
mainWindowAnimator.windowBoundsAnimateTo(savedBounds, { duration: ANIMATION_DURATION })
}
cleanupOnClosed = undefined
+42 -18
View File
@@ -1,5 +1,5 @@
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 { 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 { setupInlayWindow } from '../windows/inlay'
import { Animator } from '../windows/shared/animator'
import { computeResizedBoundsAnchoredToDominantDisplay, findDominantDisplayArea } from '../windows/shared/display'
import { toggleWindowShow } from '../windows/shared/window'
@@ -53,28 +54,37 @@ function applyWindowSize(window: BrowserWindow, width: number, height: number, x
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 { 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) {
case 'center':
window.center()
x = areaX + Math.floor((areaWidth - windowWidth) / 2)
y = areaY + Math.floor((areaHeight - windowHeight) / 2)
break
case 'top-left':
window.setPosition(areaX, areaY)
break
case 'top-right':
window.setPosition(areaX + areaWidth - windowWidth, areaY)
x = areaX + areaWidth - windowWidth
break
case 'bottom-left':
window.setPosition(areaX, areaY + areaHeight - windowHeight)
y = areaY + areaHeight - windowHeight
break
case 'bottom-right':
window.setPosition(areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight)
x = areaX + areaWidth - windowWidth
y = areaY + areaHeight - windowHeight
break
}
window.show()
return { x, y, width: windowWidth, height: windowHeight }
}
function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean {
@@ -98,6 +108,19 @@ export function setupTray(params: {
i18n: I18n
}): void {
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 })
trayImage.setTemplateImage(isMacOS)
@@ -128,25 +151,25 @@ export function setupTray(params: {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'),
type: 'checkbox',
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'),
type: 'checkbox',
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'),
type: 'checkbox',
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'),
type: 'checkbox',
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'),
type: 'checkbox',
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' },
{
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'),
type: 'checkbox',
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'),
type: 'checkbox',
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'),
type: 'checkbox',
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'),
type: 'checkbox',
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()
rebuildContextMenu.cancel()
mainWindowAnimator.stop()
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
}
}