From 2bc1c51c29572aa64a96dddcebe5ca7c54b1cb6b Mon Sep 17 00:00:00 2001 From: Neko Date: Wed, 19 Aug 2026 14:17:39 +0800 Subject: [PATCH] feat(stage-tamagotchi): animate tray window alignment (#2319) --- .../main/services/airi/onboarding/index.ts | 42 ++--------- apps/stage-tamagotchi/src/main/tray/index.ts | 60 +++++++++++----- .../src/main/windows/shared/animator.test.ts | 70 +++++++++++++++++++ .../src/main/windows/shared/animator.ts | 55 +++++++++++++++ 4 files changed, 173 insertions(+), 54 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/shared/animator.ts diff --git a/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts b/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts index c895b9069..e52980580 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts @@ -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 | 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['context'] onboardingWindowManager: OnboardingWindowManager mainWindow: BrowserWindow }) { - let currentAnimation: ReturnType | 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 diff --git a/apps/stage-tamagotchi/src/main/tray/index.ts b/apps/stage-tamagotchi/src/main/tray/index.ts index 8cd3b9aa1..61bda9759 100644 --- a/apps/stage-tamagotchi/src/main/tray/index.ts +++ b/apps/stage-tamagotchi/src/main/tray/index.ts @@ -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[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() }) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts b/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts new file mode 100644 index 000000000..df8347096 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/shared/animator.test.ts @@ -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 +} + +function waitForAnimation(): Promise { + 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() + }) +}) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/animator.ts b/apps/stage-tamagotchi/src/main/windows/shared/animator.ts new file mode 100644 index 000000000..082a35bf5 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/shared/animator.ts @@ -0,0 +1,55 @@ +import type { BrowserWindow, Rectangle } from 'electron' + +import { animate, utils } from 'animejs' + +type AnimatableWindow = Pick + +/** 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 + + 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 + } +}