feat(stage-tamagotchi): animate tray window alignment (#2319)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user