diff --git a/apps/stage-tamagotchi/build/icon-tray-macos.png b/apps/stage-tamagotchi/build/icon-tray-macos.png new file mode 100644 index 000000000..f8782f0cb Binary files /dev/null and b/apps/stage-tamagotchi/build/icon-tray-macos.png differ diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 81d4a27d8..190de9133 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -107,6 +107,7 @@ "electron-builder": "24.13.3", "electron-vite": "^2.3.0", "markdown-it-link-attributes": "^4.0.1", + "unocss-preset-scrollbar": "^3.2.0", "unplugin-auto-import": "^19.1.0", "unplugin-vue-components": "^28.4.0", "unplugin-vue-macros": "^2.14.2", diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index d86a6fb38..ab85349a5 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -1,24 +1,54 @@ import { join } from 'node:path' import { env, platform } from 'node:process' import { electronApp, is, optimizer } from '@electron-toolkit/utils' -import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron' -import { inertia } from 'popmotion' +import { nativeImage, shell } from 'electron/common' +import { app, BrowserWindow, dialog, ipcMain, Tray } from 'electron/main' +import trayIconMacos from '../../build/icon-tray-macos.png?asset' import icon from '../../build/icon.png?asset' +import { createI18n } from './locales' +import { createApplicationMenu, createTrayMenu } from './menu' -// FIXME: electron i18n - -let globalMouseTracker: ReturnType | null = null let mainWindow: BrowserWindow -let currentAnimationX: { stop: () => void } | null = null -let currentAnimationY: { stop: () => void } | null = null -let isDragging = false -let lastMousePosition = { x: 0, y: 0 } -let lastMouseTime = Date.now() -let currentVelocity = { x: 0, y: 0 } -let dragOffset = { x: 0, y: 0 } +let tray: Tray -function createWindow(): void { +const i18n = createI18n() + +function showQuitDialog() { + dialog.showMessageBox({ + type: 'info', + title: i18n.t('menu.quit'), + message: i18n.t('quitDialog.message'), + buttons: [i18n.t('quitDialog.buttons.quit'), i18n.t('quitDialog.buttons.cancel')], + }).then((result) => { + if (result.response === 0) { + mainWindow.webContents.send('before-quit') + setTimeout(() => { + app.quit() + }, 2000) + } + }) +} + +function rebuildTrayMenu() { + if (mainWindow.isVisible()) { + tray.setContextMenu(createTrayMenu(i18n, mainWindow.isVisible(), () => { + mainWindow.webContents.send('before-hide') + setTimeout(() => { + mainWindow.hide() + rebuildTrayMenu() + }, 2000) + }, createSettingsWindow, showQuitDialog)) + return + } + tray.setContextMenu(createTrayMenu(i18n, mainWindow.isVisible(), () => { + mainWindow.show() + mainWindow.webContents.send('after-show') + rebuildTrayMenu() + }, createSettingsWindow, showQuitDialog)) +} + +function createWindow() { // Create the browser window. mainWindow = new BrowserWindow({ width: 300 * 1.5, @@ -57,115 +87,11 @@ function createWindow(): void { else { mainWindow.loadFile(join(import.meta.dirname, '..', '..', 'out', 'renderer', 'index.html')) } - - ipcMain.on('start-window-drag', (_) => { - isDragging = true - const mousePos = screen.getCursorScreenPoint() - const [windowX, windowY] = mainWindow.getPosition() - - // Calculate the offset between cursor and window position - dragOffset = { - x: mousePos.x - windowX, - y: mousePos.y - windowY, - } - - // Stop any existing animations - if (currentAnimationX) { - currentAnimationX.stop() - currentAnimationX = null - } - if (currentAnimationY) { - currentAnimationY.stop() - currentAnimationY = null - } - - // Initialize last position for velocity tracking - lastMousePosition = { x: mousePos.x, y: mousePos.y } - lastMouseTime = Date.now() - currentVelocity = { x: 0, y: 0 } - - // Start global mouse tracking - if (!globalMouseTracker) { - globalMouseTracker = setInterval(() => { - const mousePos = screen.getCursorScreenPoint() - if (isDragging) { - handleWindowMove(mousePos.x, mousePos.y) - } - }, 16) // ~60fps - } - }) - - ipcMain.on('end-window-drag', () => { - isDragging = false - if (globalMouseTracker) { - clearInterval(globalMouseTracker) - globalMouseTracker = null - } - - // Apply inertia animation when drag ends - const [currentX, currentY] = mainWindow.getPosition() - let latestX = currentX - let latestY = currentY - - const inertiaConfig = { - power: 0.4, // Reduced from 0.6 for stronger resistance - timeConstant: 250, // Reduced from 400 for quicker deceleration - modifyTarget: (v: number) => v, - min: 0, - max: Infinity, - } - - // Clamp velocity to reasonable values - const clampVelocity = (v: number) => { - const maxVelocity = 500 // Reduced from 800 for less momentum - const minVelocity = -500 - return Math.min(Math.max(v, minVelocity), maxVelocity) - } - - // Reduce velocity amplification and clamp values - const amplifiedVelocity = { - x: clampVelocity(currentVelocity.x * 0.2), // Reduced from 0.3 for less momentum - y: clampVelocity(currentVelocity.y * 0.2), - } - - // Ignore very small movements - if (Math.abs(amplifiedVelocity.x) > 35 || Math.abs(amplifiedVelocity.y) > 35) { - currentAnimationX = inertia({ - from: currentX, - velocity: amplifiedVelocity.x, - ...inertiaConfig, - onUpdate: (x) => { - latestX = Math.round(x) - mainWindow.setPosition(latestX, Math.round(latestY)) - }, - onComplete: () => { - currentAnimationX = null - }, - }) - - currentAnimationY = inertia({ - from: currentY, - velocity: amplifiedVelocity.y, - ...inertiaConfig, - onUpdate: (y) => { - latestY = Math.round(y) - mainWindow.setPosition(Math.round(latestX), latestY) - }, - onComplete: () => { - currentAnimationY = null - }, - }) - } - }) - - ipcMain.on('move-window', (_, cursorX: number, cursorY: number) => { - handleWindowMove(cursorX, cursorY) - }) } let settingsWindow: BrowserWindow | null = null -function createSettingsWindow(): void { +function createSettingsWindow() { if (settingsWindow) { settingsWindow.show() return @@ -210,30 +136,7 @@ function createSettingsWindow(): void { // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.whenReady().then(() => { - // Menu - const menu = Menu.buildFromTemplate([ - { - label: 'airi', - role: 'appMenu', - submenu: [ - { - role: 'about', - }, - { - role: 'toggleDevTools', - }, - { - label: 'Settings', - click: () => createSettingsWindow(), - }, - { - label: 'Quit', - click: () => app.quit(), - }, - ], - }, - ]) - Menu.setApplicationMenu(menu) + createApplicationMenu(i18n, showQuitDialog, createSettingsWindow) // Set app user model id for windows electronApp.setAppUserModelId('com.github.moeru-ai.airi-tamagotchi') @@ -246,19 +149,7 @@ app.whenReady().then(() => { }) // IPC test - // TODO: i18n - ipcMain.on('quit', () => { - dialog.showMessageBox({ - type: 'info', - title: 'Quit', - message: 'Are you sure you want to quit?', - buttons: ['Quit', 'Cancel'], - }).then((result) => { - if (result.response === 0) { - app.quit() - } - }) - }) + ipcMain.on('quit', showQuitDialog) ipcMain.on('open-settings', () => createSettingsWindow()) @@ -271,44 +162,19 @@ app.whenReady().then(() => { createWindow() } }) + + const trayIcon = platform === 'darwin' + ? nativeImage.createFromPath(trayIconMacos).resize({ width: 16, height: 16 }) + : nativeImage.createFromPath(icon).resize({ width: 16, height: 16 }) + tray = new Tray(trayIcon) + tray.setToolTip('Airi') + rebuildTrayMenu() + + app.dock.hide() + + ipcMain.on('locale-changed', (_, language: string) => { + i18n.setLocale(language) + rebuildTrayMenu() + createApplicationMenu(i18n, showQuitDialog, createSettingsWindow) + }) }) - -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. -app.on('window-all-closed', () => { - if (platform !== 'darwin') { - app.quit() - } -}) - -// In this file you can include the rest of your app"s specific main process -// code. You can also put them in separate files and require them here. - -function handleWindowMove(cursorX: number, cursorY: number) { - if (!isDragging) - return - - // Calculate actual velocity based on mouse movement - const currentTime = Date.now() - const deltaTime = currentTime - lastMouseTime - - if (deltaTime > 0) { - // Smooth out velocity calculation with some averaging - const newVelocityX = (cursorX - lastMousePosition.x) / deltaTime * 1000 - const newVelocityY = (cursorY - lastMousePosition.y) / deltaTime * 1000 - - currentVelocity = { - x: currentVelocity.x * 0.8 + newVelocityX * 0.2, // Smooth velocity - y: currentVelocity.y * 0.8 + newVelocityY * 0.2, - } - } - - // Update window position based on cursor position and offset - const newX = cursorX - dragOffset.x - const newY = cursorY - dragOffset.y - mainWindow.setPosition(Math.round(newX), Math.round(newY)) - - lastMousePosition = { x: cursorX, y: cursorY } - lastMouseTime = currentTime -} diff --git a/apps/stage-tamagotchi/src/main/locales/en-US.ts b/apps/stage-tamagotchi/src/main/locales/en-US.ts index e5a0a972c..acd443b9d 100644 --- a/apps/stage-tamagotchi/src/main/locales/en-US.ts +++ b/apps/stage-tamagotchi/src/main/locales/en-US.ts @@ -4,10 +4,15 @@ export default { quit: 'Quit', about: 'About', toggleDevTools: 'Toggle Developer Tools', + show: 'Show airi', + hide: 'Hide airi', }, quitDialog: { title: 'Quit', message: 'Are you sure you want to quit?', - buttons: ['Quit', 'Cancel'], + buttons: { + quit: 'Quit', + cancel: 'Cancel', + }, }, } diff --git a/apps/stage-tamagotchi/src/main/locales/index.test.ts b/apps/stage-tamagotchi/src/main/locales/index.test.ts index 21b8304ca..c916ca7dc 100644 --- a/apps/stage-tamagotchi/src/main/locales/index.test.ts +++ b/apps/stage-tamagotchi/src/main/locales/index.test.ts @@ -10,6 +10,7 @@ describe('createI18n', () => { it('should return key if the key is not found', () => { const { t } = createI18n() + // @ts-expect-error for test expect(t('menu.not.found')).toBe('menu.not.found') }) @@ -21,7 +22,7 @@ describe('createI18n', () => { it('should return the correct locale in array', () => { const { t } = createI18n() - expect(t('quitDialog.buttons.0')).toBe('Quit') - expect(t('quitDialog.buttons.1')).toBe('Cancel') + expect(t('quitDialog.buttons.quit')).toBe('Quit') + expect(t('quitDialog.buttons.cancel')).toBe('Cancel') }) }) diff --git a/apps/stage-tamagotchi/src/main/locales/index.ts b/apps/stage-tamagotchi/src/main/locales/index.ts index 2a4c0c51f..2f4c135e9 100644 --- a/apps/stage-tamagotchi/src/main/locales/index.ts +++ b/apps/stage-tamagotchi/src/main/locales/index.ts @@ -7,11 +7,17 @@ const locales = { 'zh-CN': zhCN, } +type Message = typeof locales['en-US'] +type KeyOfExcludeSymbol = Exclude +type ValueOf = T[KeyOfExcludeSymbol] +type PathOf = T extends Array ? number : T extends string ? '' : Root extends true ? `${KeyOfExcludeSymbol}${PathOf, false>}` : `.${KeyOfExcludeSymbol}${PathOf, false>}` +type LocalePath = PathOf + export function createI18n() { let locale = 'en-US' let messages = locales['en-US'] - function t(key: string) { + function t(key: LocalePath) { const path = key.split('.') let current = messages let result = '' @@ -44,3 +50,5 @@ export function createI18n() { locale, } } + +export type I18n = ReturnType diff --git a/apps/stage-tamagotchi/src/main/locales/zh-CN.ts b/apps/stage-tamagotchi/src/main/locales/zh-CN.ts index 79e9cb2d7..aaf3e63ce 100644 --- a/apps/stage-tamagotchi/src/main/locales/zh-CN.ts +++ b/apps/stage-tamagotchi/src/main/locales/zh-CN.ts @@ -4,10 +4,15 @@ export default { quit: '退出', about: '关于', toggleDevTools: '切换开发者工具', + show: '显示 airi', + hide: '隐藏 airi', }, quitDialog: { title: '退出', message: '确定要退出吗?', - buttons: ['退出', '取消'], + buttons: { + quit: '退出', + cancel: '取消', + }, }, } diff --git a/apps/stage-tamagotchi/src/main/menu.ts b/apps/stage-tamagotchi/src/main/menu.ts new file mode 100644 index 000000000..abee768f1 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/menu.ts @@ -0,0 +1,65 @@ +import type { I18n } from './locales' + +import { Menu } from 'electron/main' + +export function createBasicMenu( + { t }: I18n, + createSettingsWindow: () => void, + onQuitClick: () => void, +): Array<(Electron.MenuItemConstructorOptions)> { + return [ + { + role: 'about', + label: t('menu.about'), + }, + { + label: t('menu.settings'), + click: createSettingsWindow, + }, + { + label: t('menu.quit'), + click: onQuitClick, + }, + ] +} + +export function createTrayMenu( + i18n: I18n, + isVisible: boolean, + onVisibleChange: () => void, + createSettingsWindow: () => void, + onQuitClick: () => void, +) { + const menu = createBasicMenu(i18n, createSettingsWindow, onQuitClick) + if (isVisible) { + menu.push({ + label: i18n.t('menu.hide'), + click: onVisibleChange, + }) + } + else { + menu.push({ + label: i18n.t('menu.show'), + click: onVisibleChange, + }) + } + return Menu.buildFromTemplate(menu) +} + +export function createApplicationMenu( + i18n: I18n, + onQuitClick: () => void, + createSettingsWindow: () => void, +) { + const menu = Menu.buildFromTemplate([ + { + label: 'airi', + role: 'appMenu', + submenu: createBasicMenu(i18n, createSettingsWindow, onQuitClick), + }, + { + role: 'editMenu', + }, + ]) + Menu.setApplicationMenu(menu) +} diff --git a/apps/stage-tamagotchi/src/renderer/src/App.vue b/apps/stage-tamagotchi/src/renderer/src/App.vue index 339f078a5..049ccfac7 100644 --- a/apps/stage-tamagotchi/src/renderer/src/App.vue +++ b/apps/stage-tamagotchi/src/renderer/src/App.vue @@ -1,6 +1,6 @@