Skip to content

Commit

Permalink
feat(stage-tamagotchi): system tray (#32)
Browse files Browse the repository at this point in the history
* fix(tamagotchi): cannot use default select copy and paste
* style(tamagotchi): scrollbar
* fix(tamagotchi): set motion
* feat(tamagotchi): system tray
* fix: linter issue
* fix: typecheck
* fix: electron declaration
  • Loading branch information
LemonNekoGH authored Feb 23, 2025
1 parent a15457a commit 636371e
Show file tree
Hide file tree
Showing 16 changed files with 269 additions and 208 deletions.
Binary file added apps/stage-tamagotchi/build/icon-tray-macos.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/stage-tamagotchi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
252 changes: 59 additions & 193 deletions apps/stage-tamagotchi/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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

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(): void {
function createWindow() {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 300 * 1.5,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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())

Expand All @@ -271,44 +162,19 @@ app.whenReady().then(() => {
createWindow()
}
})
})

// 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()
}
})
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()

// 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
app.dock.hide()

// 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
}
ipcMain.on('locale-changed', (_, language: string) => {
i18n.setLocale(language)
rebuildTrayMenu()
createApplicationMenu(i18n, showQuitDialog, createSettingsWindow)
})
})
7 changes: 6 additions & 1 deletion apps/stage-tamagotchi/src/main/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
}
5 changes: 3 additions & 2 deletions apps/stage-tamagotchi/src/main/locales/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})

Expand All @@ -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')
})
})
10 changes: 9 additions & 1 deletion apps/stage-tamagotchi/src/main/locales/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ const locales = {
'zh-CN': zhCN,
}

type Message = typeof locales['en-US']
type KeyOfExcludeSymbol<T> = Exclude<keyof T, symbol>
type ValueOf<T> = T[KeyOfExcludeSymbol<T>]
type PathOf<T, Root extends boolean = true> = T extends Array<any> ? number : T extends string ? '' : Root extends true ? `${KeyOfExcludeSymbol<T>}${PathOf<ValueOf<T>, false>}` : `.${KeyOfExcludeSymbol<T>}${PathOf<ValueOf<T>, false>}`
type LocalePath = PathOf<Message>

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 = ''
Expand Down Expand Up @@ -44,3 +50,5 @@ export function createI18n() {
locale,
}
}

export type I18n = ReturnType<typeof createI18n>
7 changes: 6 additions & 1 deletion apps/stage-tamagotchi/src/main/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ export default {
quit: '退出',
about: '关于',
toggleDevTools: '切换开发者工具',
show: '显示 airi',
hide: '隐藏 airi',
},
quitDialog: {
title: '退出',
message: '确定要退出吗?',
buttons: ['退出', '取消'],
buttons: {
quit: '退出',
cancel: '取消',
},
},
}
Loading

0 comments on commit 636371e

Please sign in to comment.