diff --git a/apps/stage-tamagotchi/src/main/services/electron/app.ts b/apps/stage-tamagotchi/src/main/services/electron/app.ts index 2f58f4d1c..8daab8dc7 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/app.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/app.ts @@ -2,12 +2,14 @@ import type { createContext } from '@moeru/eventa/adapters/electron/main' import type { BrowserWindow } from 'electron' import { defineInvokeHandler } from '@moeru/eventa' +import { app } from 'electron' import { isLinux, isMacOS, isWindows } from 'std-env' -import { electron } from '../../../shared/eventa' +import { electron, electronAppQuit } from '../../../shared/eventa' export function createAppService(params: { context: ReturnType['context'], window: BrowserWindow }) { defineInvokeHandler(params.context, electron.app.isMacOS, () => isMacOS) defineInvokeHandler(params.context, electron.app.isWindows, () => isWindows) defineInvokeHandler(params.context, electron.app.isLinux, () => isLinux) + defineInvokeHandler(params.context, electronAppQuit, () => app.quit()) } diff --git a/apps/stage-tamagotchi/src/main/services/electron/window.ts b/apps/stage-tamagotchi/src/main/services/electron/window.ts index b92f55619..b37732c11 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/window.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/window.ts @@ -5,7 +5,7 @@ import { defineInvokeHandler } from '@moeru/eventa' import { bounds, startLoopGetBounds } from '@proj-airi/electron-eventa' import { createRendererLoop } from '@proj-airi/electron-vueuse/main' -import { electron } from '../../../shared/eventa' +import { electron, electronWindowClose } from '../../../shared/eventa' import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle' import { resizeWindowByDelta } from '../../windows/shared/window' @@ -70,4 +70,10 @@ export function createWindowService(params: { context: ReturnType { + if (params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) { + params.window.close() + } + }) } diff --git a/apps/stage-tamagotchi/src/main/tray/index.ts b/apps/stage-tamagotchi/src/main/tray/index.ts index 0a1c06d2a..d47ddefd3 100644 --- a/apps/stage-tamagotchi/src/main/tray/index.ts +++ b/apps/stage-tamagotchi/src/main/tray/index.ts @@ -7,8 +7,8 @@ import type { WidgetsWindowManager } from '../windows/widgets' import { env } from 'node:process' import { is } from '@electron-toolkit/utils' -import { app, Menu, nativeImage, Tray } from 'electron' -import { once } from 'es-toolkit' +import { app, Menu, nativeImage, screen, Tray } from 'electron' +import { debounce, once } from 'es-toolkit' import { isMacOS } from 'std-env' import icon from '../../../resources/icon.png?asset' @@ -18,6 +18,61 @@ import { onAppBeforeQuit } from '../libs/bootkit/lifecycle' import { setupInlayWindow } from '../windows/inlay' import { toggleWindowShow } from '../windows/shared/window' +const RECOMMENDED_WIDTH = 450 +const RECOMMENDED_HEIGHT = 600 +const ASPECT_RATIO = RECOMMENDED_WIDTH / RECOMMENDED_HEIGHT + +function applyWindowSize(window: BrowserWindow, width: number, height: number, x?: number, y?: number): void { + window.setResizable(true) + const bounds = { + width: Math.round(width), + height: Math.round(height), + } as any + if (x !== undefined && y !== undefined) { + bounds.x = Math.round(x) + bounds.y = Math.round(y) + } + window.setBounds(bounds) + if (x === undefined || y === undefined) { + window.center() + } + window.show() +} + +function alignWindow(window: BrowserWindow, position: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'): void { + const { width: windowWidth, height: windowHeight } = window.getBounds() + const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = screen.getPrimaryDisplay().workArea + + switch (position) { + case 'center': + window.center() + break + case 'top-left': + window.setPosition(areaX, areaY) + break + case 'top-right': + window.setPosition(areaX + areaWidth - windowWidth, areaY) + break + case 'bottom-left': + window.setPosition(areaX, areaY + areaHeight - windowHeight) + break + case 'bottom-right': + window.setPosition(areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight) + break + } + window.show() +} + +function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean { + const { width, height } = window.getBounds() + return Math.abs(width - Math.round(targetWidth)) <= 2 && Math.abs(height - Math.round(targetHeight)) <= 2 +} + +function isPositionMatch(window: BrowserWindow, targetX: number, targetY: number): boolean { + const { x, y } = window.getBounds() + return Math.abs(x - targetX) <= 5 && Math.abs(y - targetY) <= 5 +} + export function setupTray(params: { mainWindow: BrowserWindow settingsWindow: () => Promise @@ -33,44 +88,117 @@ export function setupTray(params: { const appTray = new Tray(trayImage) onAppBeforeQuit(() => appTray.destroy()) - const contextMenu = Menu.buildFromTemplate([ - { label: 'Show', click: () => toggleWindowShow(params.mainWindow) }, - { type: 'separator' }, - { - label: 'Center and Reset Size', - click: () => { - params.mainWindow.setSize(450, 600) - params.mainWindow.center() - params.mainWindow.show() + const rebuildContextMenu = debounce((): void => { + const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = screen.getPrimaryDisplay().workArea + const { width: windowWidth, height: windowHeight } = params.mainWindow.getBounds() + + const fullHeightTarget = areaHeight + const fullWidthTarget = Math.floor(areaHeight * ASPECT_RATIO) + const halfHeightTarget = Math.floor(areaHeight / 2) + const halfWidthTarget = Math.floor(halfHeightTarget * ASPECT_RATIO) + + const contextMenu = Menu.buildFromTemplate([ + { label: 'Show', click: () => toggleWindowShow(params.mainWindow) }, + { type: 'separator' }, + { + label: 'Adjust Sizes', + submenu: [ + { + label: 'Recommended (450x600)', + type: 'checkbox', + checked: isSizeMatch(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT), + click: () => applyWindowSize(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT), + }, + { + label: 'Full Height', + type: 'checkbox', + checked: isSizeMatch(params.mainWindow, fullWidthTarget, fullHeightTarget), + click: () => applyWindowSize(params.mainWindow, fullWidthTarget, fullHeightTarget), + }, + { + label: 'Half Height', + type: 'checkbox', + checked: isSizeMatch(params.mainWindow, halfWidthTarget, halfHeightTarget), + click: () => applyWindowSize(params.mainWindow, halfWidthTarget, halfHeightTarget), + }, + { + label: 'Full Screen', + type: 'checkbox', + checked: isSizeMatch(params.mainWindow, areaWidth, areaHeight), + click: () => applyWindowSize(params.mainWindow, areaWidth, areaHeight, areaX, areaY), + }, + ], }, - }, - { type: 'separator' }, - { label: 'Settings...', click: () => params.settingsWindow().then(window => toggleWindowShow(window)) }, - { label: 'About...', click: () => params.aboutWindow().then(window => toggleWindowShow(window)) }, - { type: 'separator' }, - { label: 'Open Inlay...', click: () => setupInlayWindow() }, - { label: 'Open Widgets...', click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) }, - { label: 'Open Caption...', click: () => params.captionWindow.getWindow().then(window => toggleWindowShow(window)) }, - { - type: 'submenu', - label: 'Caption Overlay', - submenu: Menu.buildFromTemplate([ - { type: 'checkbox', label: 'Follow window', checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)) }, - { label: 'Reset position', click: async () => await params.captionWindow.resetToSide() }, - ]), - }, - { type: 'separator' }, - ...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG - ? [ - { type: 'header', label: 'DevTools' }, - { label: 'Troubleshoot BeatSync...', click: () => params.beatSyncBgWindow.webContents.openDevTools() }, + { + label: 'Align to', + submenu: [ + { + 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'), + }, { type: 'separator' }, - ] as const // :( - : [], - { label: 'Quit', click: () => app.quit() }, - ]) + { + label: 'Top Left', + type: 'checkbox', + checked: isPositionMatch(params.mainWindow, areaX, areaY), + click: () => alignWindow(params.mainWindow, 'top-left'), + }, + { + label: 'Top Right', + type: 'checkbox', + checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY), + click: () => alignWindow(params.mainWindow, 'top-right'), + }, + { + label: 'Bottom Left', + type: 'checkbox', + checked: isPositionMatch(params.mainWindow, areaX, areaY + areaHeight - windowHeight), + click: () => alignWindow(params.mainWindow, 'bottom-left'), + }, + { + label: 'Bottom Right', + type: 'checkbox', + checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight), + click: () => alignWindow(params.mainWindow, 'bottom-right'), + }, + ], + }, + { type: 'separator' }, + { label: 'Settings...', click: () => params.settingsWindow().then(window => toggleWindowShow(window)) }, + { label: 'About...', click: () => params.aboutWindow().then(window => toggleWindowShow(window)) }, + { type: 'separator' }, + { label: 'Open Inlay...', click: () => setupInlayWindow() }, + { label: 'Open Widgets...', click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) }, + { label: 'Open Caption...', click: () => params.captionWindow.getWindow().then(window => toggleWindowShow(window)) }, + { + type: 'submenu', + label: 'Caption Overlay', + submenu: Menu.buildFromTemplate([ + { type: 'checkbox', label: 'Follow window', checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)) }, + { label: 'Reset position', click: async () => await params.captionWindow.resetToSide() }, + ]), + }, + { type: 'separator' }, + ...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG + ? [ + { type: 'header', label: 'DevTools' }, + { label: 'Troubleshoot BeatSync...', click: () => params.beatSyncBgWindow.webContents.openDevTools() }, + { type: 'separator' }, + ] as const + : [], + { label: 'Quit', click: () => app.quit() }, + ]) + + appTray.setContextMenu(contextMenu) + }, 50) + + params.mainWindow.on('resize', rebuildContextMenu) + params.mainWindow.on('move', rebuildContextMenu) + + rebuildContextMenu() - appTray.setContextMenu(contextMenu) appTray.setToolTip('Project AIRI') appTray.addListener('click', () => toggleWindowShow(params.mainWindow)) diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue index 22839f3cf..06d65b829 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue @@ -1,11 +1,10 @@ diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index 1e72eb5c4..212e2bb19 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -194,6 +194,9 @@ export const widgetsUpdate = defineInvokeEventa('eventa:invoke:electron:windows:widgets:fetch') export const widgetsPrepareWindow = defineInvokeEventa('eventa:invoke:electron:windows:widgets:prepare') +export const electronWindowClose = defineInvokeEventa('eventa:invoke:electron:window:close') +export const electronAppQuit = defineInvokeEventa('eventa:invoke:electron:app:quit') + // Internal event from main -> widgets renderer when a widget should render export const widgetsRenderEvent = defineEventa('eventa:event:electron:windows:widgets:render') export const widgetsRemoveEvent = defineEventa<{ id: string }>('eventa:event:electron:windows:widgets:remove') diff --git a/packages/electron-eventa/src/electron/app.ts b/packages/electron-eventa/src/electron/app.ts index aa141be81..3669b73e8 100644 --- a/packages/electron-eventa/src/electron/app.ts +++ b/packages/electron-eventa/src/electron/app.ts @@ -3,9 +3,11 @@ import { defineInvokeEventa } from '@moeru/eventa' const isMacOS = defineInvokeEventa('eventa:invoke:electron:app:is-macos') const isWindows = defineInvokeEventa('eventa:invoke:electron:app:is-windows') const isLinux = defineInvokeEventa('eventa:invoke:electron:app:is-linux') +const quit = defineInvokeEventa('eventa:invoke:electron:app:quit') export const app = { isMacOS, isWindows, isLinux, + quit, } diff --git a/packages/electron-eventa/src/electron/window.ts b/packages/electron-eventa/src/electron/window.ts index 858c3c61c..ac5810a5f 100644 --- a/packages/electron-eventa/src/electron/window.ts +++ b/packages/electron-eventa/src/electron/window.ts @@ -11,6 +11,7 @@ const setIgnoreMouseEvents = defineInvokeEventa | [null]>('eventa:invoke:electron:window:set-vibrancy') const setBackgroundMaterial = defineInvokeEventa>('eventa:invoke:electron:window:set-background-material') const resize = defineInvokeEventa('eventa:invoke:electron:window:resize') +const close = defineInvokeEventa('eventa:invoke:electron:window:close') export type VibrancyType = Parameters[0] export type BackgroundMaterialType = Parameters[0] @@ -23,4 +24,5 @@ export const window = { setVibrancy, setBackgroundMaterial, resize, + close, } diff --git a/packages/i18n/src/locales/en/tamagotchi/stage.yaml b/packages/i18n/src/locales/en/tamagotchi/stage.yaml index ee379f84d..ecf5008c3 100644 --- a/packages/i18n/src/locales/en/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/en/tamagotchi/stage.yaml @@ -19,6 +19,9 @@ docs: 'drag-to-move-window': Drag to move window 'switch-to-light-mode': Switch to light mode 'switch-to-dark-mode': Switch to dark mode + 'close': Close + 'expand': Expand + 'collapse': Collapse notice: 'fade-on-hover': diff --git a/packages/i18n/src/locales/fr/tamagotchi/stage.yaml b/packages/i18n/src/locales/fr/tamagotchi/stage.yaml index 9f83dc880..ea8d115bc 100644 --- a/packages/i18n/src/locales/fr/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/fr/tamagotchi/stage.yaml @@ -19,6 +19,7 @@ docs: 'drag-to-move-window': Drag to move window 'switch-to-light-mode': Switch to light mode 'switch-to-dark-mode': Switch to dark mode + 'close': Fermer notice: 'fade-on-hover': title: Disparaître au survol diff --git a/packages/i18n/src/locales/ja/tamagotchi/stage.yaml b/packages/i18n/src/locales/ja/tamagotchi/stage.yaml index bba375c9a..4288f9fe4 100644 --- a/packages/i18n/src/locales/ja/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/ja/tamagotchi/stage.yaml @@ -19,6 +19,9 @@ docs: 'drag-to-move-window': ウィンドウをドラッグして移動 'switch-to-light-mode': ライトモードに切り替え 'switch-to-dark-mode': ダークモードに切り替え + 'close': 閉じる + 'expand': 展開 + 'collapse': 折りたたむ notice: 'fade-on-hover': title: ホバー時にフェード diff --git a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml index 4283302bf..84b390a88 100644 --- a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml @@ -19,6 +19,9 @@ docs: 'drag-to-move-window': 拖动以移动窗口 'switch-to-light-mode': 切换到亮色模式 'switch-to-dark-mode': 切换到暗色模式 + 'close': 关闭 + 'expand': 展开 + 'collapse': 收起 notice: 'fade-on-hover': title: 悬停淡出 diff --git a/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml b/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml index 549841b4e..f66810cad 100644 --- a/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml @@ -18,7 +18,11 @@ docs: 'open-hearing-controls': 開啟聽力控制 'drag-to-move-window': 長按以移動視窗 'switch-to-light-mode': 切換至亮色模式 - 'switch-to-dark-mode': 切換至深色模式 + 'switch-to-dark-mode': 切換到暗色模式 + 'close': 關閉 + 'expand': 展開 + 'collapse': 收起 + notice: 'fade-on-hover': title: 懸停淡出