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 95eeddcdf..d398b2681 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/onboarding/index.ts @@ -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 | 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['context'] onboardingWindowManager: OnboardingWindowManager + mainWindow: BrowserWindow }) { + let currentAnimation: ReturnType | 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 + }) }) } diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index 22f6226a7..0889903a0 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -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' })) diff --git a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts index 8f10dd5b6..e0d087fb5 100644 --- a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts @@ -22,6 +22,7 @@ import { setupBaseWindowElectronInvokes } from '../shared/window' export interface OnboardingWindowManager { getWindow: () => Promise getAndToggleWindow: () => Promise + 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) { 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) + } + }, } } diff --git a/apps/stage-tamagotchi/src/main/windows/shared/display.ts b/apps/stage-tamagotchi/src/main/windows/shared/display.ts index 68a9327a8..e90a3cfb2 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/display.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/display.ts @@ -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. *