feat(stage-tamagotchi): move main-stage window to avoid overlap with onboarding window

This commit is contained in:
Makito
2026-04-10 04:44:50 +09:00
parent a0caaf3214
commit b11f251dd0
4 changed files with 130 additions and 2 deletions
@@ -1,16 +1,90 @@
import type { createContext } from '@moeru/eventa/adapters/electron/main'
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 { computeAdjacentPosition } from '../../../windows/shared/display'
const ANIMATION_DURATION = 350
function animateWindowPosition(
window: BrowserWindow,
targetX: number,
targetY: number,
): ReturnType<typeof animate> | undefined {
if (window.isDestroyed())
return undefined
const bounds = window.getBounds()
const state = { x: bounds.x, y: bounds.y }
return animate(state, {
x: targetX,
y: targetY,
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
let cleanupOnClosed: (() => void) | undefined
defineInvokeHandler(params.context, electronOpenOnboarding, async () => {
await params.onboardingWindowManager.getAndToggleWindow()
const savedBounds = params.mainWindow.getBounds()
const onboardingWindow = await params.onboardingWindowManager.getAndToggleWindow()
const onboardingBounds = onboardingWindow.getBounds()
const display = screen.getDisplayMatching(onboardingBounds)
const newPosition = computeAdjacentPosition(
onboardingBounds,
{ width: savedBounds.width, height: savedBounds.height },
display.workArea,
)
currentAnimation?.pause()
currentAnimation = animateWindowPosition(params.mainWindow, newPosition.x, newPosition.y)
let userMovedManually = false
let ignoreNextMoves = true
const moveListener = () => {
if (ignoreNextMoves)
return
userMovedManually = true
}
params.mainWindow.on('move', moveListener)
setTimeout(() => {
ignoreNextMoves = false
}, ANIMATION_DURATION + 50)
cleanupOnClosed?.()
cleanupOnClosed = params.onboardingWindowManager.onClosed(() => {
params.mainWindow.removeListener('move', moveListener)
if (!userMovedManually && !params.mainWindow.isDestroyed()) {
currentAnimation?.pause()
currentAnimation = animateWindowPosition(params.mainWindow, savedBounds.x, savedBounds.y)
}
cleanupOnClosed = undefined
})
})
}
@@ -47,7 +47,7 @@ export async function setupMainWindowElectronInvokes(params: {
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window })
createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater })
createMcpServersService({ context, manager: params.mcpStdioManager })
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager })
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window })
createAuthService({ context, window: params.window, windowAuthManager: params.windowAuthManager })
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
@@ -22,6 +22,7 @@ import { setupBaseWindowElectronInvokes } from '../shared/window'
export interface OnboardingWindowManager {
getWindow: () => Promise<BrowserWindow>
getAndToggleWindow: () => Promise<BrowserWindow>
onClosed: (callback: () => void) => () => void
}
export function setupOnboardingWindowManager(params: {
@@ -29,6 +30,8 @@ export function setupOnboardingWindowManager(params: {
i18n: I18n
windowAuthManager: WindowAuthManager
}): OnboardingWindowManager {
const closeCallbacks = new Set<() => void>()
async function getOnboardingWindow(getWindow: () => Promise<BrowserWindow>) {
const window = await getWindow()
await toggleWindowShow(window)
@@ -78,11 +81,26 @@ export function setupOnboardingWindowManager(params: {
await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding'))
newWindow.on('closed', () => {
for (const cb of closeCallbacks) {
try {
cb()
}
catch { /* noop */ }
}
})
return newWindow
})
return {
getWindow: async () => reusableWindow.getWindow(),
getAndToggleWindow: async () => await getOnboardingWindow(reusableWindow.getWindow),
onClosed: (callback: () => void) => {
closeCallbacks.add(callback)
return () => {
closeCallbacks.delete(callback)
}
},
}
}
@@ -141,6 +141,42 @@ export function widthFrom(bounds: Rectangle, sizeOptions: Size & { min?: Size, m
return val
}
/**
* Compute a position for `target` adjacent to `anchor`, staying within `workArea`.
*/
export function computeAdjacentPosition(
anchorBounds: Rectangle,
targetSize: { width: number, height: number },
workArea: Rectangle,
offset = 16,
): { x: number, y: number } {
const centerY = anchorBounds.y + Math.floor((anchorBounds.height - targetSize.height) / 2)
const clampY = (y: number) => Math.min(
Math.max(y, workArea.y),
workArea.y + workArea.height - targetSize.height,
)
// Try right
const rightX = anchorBounds.x + anchorBounds.width + offset
if (rightX + targetSize.width <= workArea.x + workArea.width) {
return { x: rightX, y: clampY(centerY) }
}
// Try left
const leftX = anchorBounds.x - targetSize.width - offset
if (leftX >= workArea.x) {
return { x: leftX, y: clampY(centerY) }
}
// Fall back to below
const belowY = anchorBounds.y + anchorBounds.height + offset
const belowX = anchorBounds.x + Math.floor((anchorBounds.width - targetSize.width) / 2)
return {
x: Math.min(Math.max(belowX, workArea.x), workArea.x + workArea.width - targetSize.width),
y: Math.min(belowY, workArea.y + workArea.height - targetSize.height),
}
}
/**
* Calculate height based on options similar to how Web CSS does it.
*