Skip to content

Commit 636371e

Browse files
authored
feat(stage-tamagotchi): system tray (#32)
* 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
1 parent a15457a commit 636371e

File tree

16 files changed

+269
-208
lines changed

16 files changed

+269
-208
lines changed
13.3 KB
Loading

apps/stage-tamagotchi/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"electron-builder": "24.13.3",
108108
"electron-vite": "^2.3.0",
109109
"markdown-it-link-attributes": "^4.0.1",
110+
"unocss-preset-scrollbar": "^3.2.0",
110111
"unplugin-auto-import": "^19.1.0",
111112
"unplugin-vue-components": "^28.4.0",
112113
"unplugin-vue-macros": "^2.14.2",
+59-193
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,54 @@
11
import { join } from 'node:path'
22
import { env, platform } from 'node:process'
33
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
4-
import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron'
5-
import { inertia } from 'popmotion'
4+
import { nativeImage, shell } from 'electron/common'
5+
import { app, BrowserWindow, dialog, ipcMain, Tray } from 'electron/main'
66

7+
import trayIconMacos from '../../build/icon-tray-macos.png?asset'
78
import icon from '../../build/icon.png?asset'
9+
import { createI18n } from './locales'
10+
import { createApplicationMenu, createTrayMenu } from './menu'
811

9-
// FIXME: electron i18n
10-
11-
let globalMouseTracker: ReturnType<typeof setInterval> | null = null
1212
let mainWindow: BrowserWindow
13-
let currentAnimationX: { stop: () => void } | null = null
14-
let currentAnimationY: { stop: () => void } | null = null
15-
let isDragging = false
16-
let lastMousePosition = { x: 0, y: 0 }
17-
let lastMouseTime = Date.now()
18-
let currentVelocity = { x: 0, y: 0 }
19-
let dragOffset = { x: 0, y: 0 }
13+
let tray: Tray
14+
15+
const i18n = createI18n()
16+
17+
function showQuitDialog() {
18+
dialog.showMessageBox({
19+
type: 'info',
20+
title: i18n.t('menu.quit'),
21+
message: i18n.t('quitDialog.message'),
22+
buttons: [i18n.t('quitDialog.buttons.quit'), i18n.t('quitDialog.buttons.cancel')],
23+
}).then((result) => {
24+
if (result.response === 0) {
25+
mainWindow.webContents.send('before-quit')
26+
setTimeout(() => {
27+
app.quit()
28+
}, 2000)
29+
}
30+
})
31+
}
32+
33+
function rebuildTrayMenu() {
34+
if (mainWindow.isVisible()) {
35+
tray.setContextMenu(createTrayMenu(i18n, mainWindow.isVisible(), () => {
36+
mainWindow.webContents.send('before-hide')
37+
setTimeout(() => {
38+
mainWindow.hide()
39+
rebuildTrayMenu()
40+
}, 2000)
41+
}, createSettingsWindow, showQuitDialog))
42+
return
43+
}
44+
tray.setContextMenu(createTrayMenu(i18n, mainWindow.isVisible(), () => {
45+
mainWindow.show()
46+
mainWindow.webContents.send('after-show')
47+
rebuildTrayMenu()
48+
}, createSettingsWindow, showQuitDialog))
49+
}
2050

21-
function createWindow(): void {
51+
function createWindow() {
2252
// Create the browser window.
2353
mainWindow = new BrowserWindow({
2454
width: 300 * 1.5,
@@ -57,115 +87,11 @@ function createWindow(): void {
5787
else {
5888
mainWindow.loadFile(join(import.meta.dirname, '..', '..', 'out', 'renderer', 'index.html'))
5989
}
60-
61-
ipcMain.on('start-window-drag', (_) => {
62-
isDragging = true
63-
const mousePos = screen.getCursorScreenPoint()
64-
const [windowX, windowY] = mainWindow.getPosition()
65-
66-
// Calculate the offset between cursor and window position
67-
dragOffset = {
68-
x: mousePos.x - windowX,
69-
y: mousePos.y - windowY,
70-
}
71-
72-
// Stop any existing animations
73-
if (currentAnimationX) {
74-
currentAnimationX.stop()
75-
currentAnimationX = null
76-
}
77-
if (currentAnimationY) {
78-
currentAnimationY.stop()
79-
currentAnimationY = null
80-
}
81-
82-
// Initialize last position for velocity tracking
83-
lastMousePosition = { x: mousePos.x, y: mousePos.y }
84-
lastMouseTime = Date.now()
85-
currentVelocity = { x: 0, y: 0 }
86-
87-
// Start global mouse tracking
88-
if (!globalMouseTracker) {
89-
globalMouseTracker = setInterval(() => {
90-
const mousePos = screen.getCursorScreenPoint()
91-
if (isDragging) {
92-
handleWindowMove(mousePos.x, mousePos.y)
93-
}
94-
}, 16) // ~60fps
95-
}
96-
})
97-
98-
ipcMain.on('end-window-drag', () => {
99-
isDragging = false
100-
if (globalMouseTracker) {
101-
clearInterval(globalMouseTracker)
102-
globalMouseTracker = null
103-
}
104-
105-
// Apply inertia animation when drag ends
106-
const [currentX, currentY] = mainWindow.getPosition()
107-
let latestX = currentX
108-
let latestY = currentY
109-
110-
const inertiaConfig = {
111-
power: 0.4, // Reduced from 0.6 for stronger resistance
112-
timeConstant: 250, // Reduced from 400 for quicker deceleration
113-
modifyTarget: (v: number) => v,
114-
min: 0,
115-
max: Infinity,
116-
}
117-
118-
// Clamp velocity to reasonable values
119-
const clampVelocity = (v: number) => {
120-
const maxVelocity = 500 // Reduced from 800 for less momentum
121-
const minVelocity = -500
122-
return Math.min(Math.max(v, minVelocity), maxVelocity)
123-
}
124-
125-
// Reduce velocity amplification and clamp values
126-
const amplifiedVelocity = {
127-
x: clampVelocity(currentVelocity.x * 0.2), // Reduced from 0.3 for less momentum
128-
y: clampVelocity(currentVelocity.y * 0.2),
129-
}
130-
131-
// Ignore very small movements
132-
if (Math.abs(amplifiedVelocity.x) > 35 || Math.abs(amplifiedVelocity.y) > 35) {
133-
currentAnimationX = inertia({
134-
from: currentX,
135-
velocity: amplifiedVelocity.x,
136-
...inertiaConfig,
137-
onUpdate: (x) => {
138-
latestX = Math.round(x)
139-
mainWindow.setPosition(latestX, Math.round(latestY))
140-
},
141-
onComplete: () => {
142-
currentAnimationX = null
143-
},
144-
})
145-
146-
currentAnimationY = inertia({
147-
from: currentY,
148-
velocity: amplifiedVelocity.y,
149-
...inertiaConfig,
150-
onUpdate: (y) => {
151-
latestY = Math.round(y)
152-
mainWindow.setPosition(Math.round(latestX), latestY)
153-
},
154-
onComplete: () => {
155-
currentAnimationY = null
156-
},
157-
})
158-
}
159-
})
160-
161-
ipcMain.on('move-window', (_, cursorX: number, cursorY: number) => {
162-
handleWindowMove(cursorX, cursorY)
163-
})
16490
}
16591

16692
let settingsWindow: BrowserWindow | null = null
16793

168-
function createSettingsWindow(): void {
94+
function createSettingsWindow() {
16995
if (settingsWindow) {
17096
settingsWindow.show()
17197
return
@@ -210,30 +136,7 @@ function createSettingsWindow(): void {
210136
// initialization and is ready to create browser windows.
211137
// Some APIs can only be used after this event occurs.
212138
app.whenReady().then(() => {
213-
// Menu
214-
const menu = Menu.buildFromTemplate([
215-
{
216-
label: 'airi',
217-
role: 'appMenu',
218-
submenu: [
219-
{
220-
role: 'about',
221-
},
222-
{
223-
role: 'toggleDevTools',
224-
},
225-
{
226-
label: 'Settings',
227-
click: () => createSettingsWindow(),
228-
},
229-
{
230-
label: 'Quit',
231-
click: () => app.quit(),
232-
},
233-
],
234-
},
235-
])
236-
Menu.setApplicationMenu(menu)
139+
createApplicationMenu(i18n, showQuitDialog, createSettingsWindow)
237140

238141
// Set app user model id for windows
239142
electronApp.setAppUserModelId('com.github.moeru-ai.airi-tamagotchi')
@@ -246,19 +149,7 @@ app.whenReady().then(() => {
246149
})
247150

248151
// IPC test
249-
// TODO: i18n
250-
ipcMain.on('quit', () => {
251-
dialog.showMessageBox({
252-
type: 'info',
253-
title: 'Quit',
254-
message: 'Are you sure you want to quit?',
255-
buttons: ['Quit', 'Cancel'],
256-
}).then((result) => {
257-
if (result.response === 0) {
258-
app.quit()
259-
}
260-
})
261-
})
152+
ipcMain.on('quit', showQuitDialog)
262153

263154
ipcMain.on('open-settings', () => createSettingsWindow())
264155

@@ -271,44 +162,19 @@ app.whenReady().then(() => {
271162
createWindow()
272163
}
273164
})
274-
})
275165

276-
// Quit when all windows are closed, except on macOS. There, it's common
277-
// for applications and their menu bar to stay active until the user quits
278-
// explicitly with Cmd + Q.
279-
app.on('window-all-closed', () => {
280-
if (platform !== 'darwin') {
281-
app.quit()
282-
}
283-
})
166+
const trayIcon = platform === 'darwin'
167+
? nativeImage.createFromPath(trayIconMacos).resize({ width: 16, height: 16 })
168+
: nativeImage.createFromPath(icon).resize({ width: 16, height: 16 })
169+
tray = new Tray(trayIcon)
170+
tray.setToolTip('Airi')
171+
rebuildTrayMenu()
284172

285-
// In this file you can include the rest of your app"s specific main process
286-
// code. You can also put them in separate files and require them here.
287-
288-
function handleWindowMove(cursorX: number, cursorY: number) {
289-
if (!isDragging)
290-
return
173+
app.dock.hide()
291174

292-
// Calculate actual velocity based on mouse movement
293-
const currentTime = Date.now()
294-
const deltaTime = currentTime - lastMouseTime
295-
296-
if (deltaTime > 0) {
297-
// Smooth out velocity calculation with some averaging
298-
const newVelocityX = (cursorX - lastMousePosition.x) / deltaTime * 1000
299-
const newVelocityY = (cursorY - lastMousePosition.y) / deltaTime * 1000
300-
301-
currentVelocity = {
302-
x: currentVelocity.x * 0.8 + newVelocityX * 0.2, // Smooth velocity
303-
y: currentVelocity.y * 0.8 + newVelocityY * 0.2,
304-
}
305-
}
306-
307-
// Update window position based on cursor position and offset
308-
const newX = cursorX - dragOffset.x
309-
const newY = cursorY - dragOffset.y
310-
mainWindow.setPosition(Math.round(newX), Math.round(newY))
311-
312-
lastMousePosition = { x: cursorX, y: cursorY }
313-
lastMouseTime = currentTime
314-
}
175+
ipcMain.on('locale-changed', (_, language: string) => {
176+
i18n.setLocale(language)
177+
rebuildTrayMenu()
178+
createApplicationMenu(i18n, showQuitDialog, createSettingsWindow)
179+
})
180+
})

apps/stage-tamagotchi/src/main/locales/en-US.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ export default {
44
quit: 'Quit',
55
about: 'About',
66
toggleDevTools: 'Toggle Developer Tools',
7+
show: 'Show airi',
8+
hide: 'Hide airi',
79
},
810
quitDialog: {
911
title: 'Quit',
1012
message: 'Are you sure you want to quit?',
11-
buttons: ['Quit', 'Cancel'],
13+
buttons: {
14+
quit: 'Quit',
15+
cancel: 'Cancel',
16+
},
1217
},
1318
}

apps/stage-tamagotchi/src/main/locales/index.test.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe('createI18n', () => {
1010

1111
it('should return key if the key is not found', () => {
1212
const { t } = createI18n()
13+
// @ts-expect-error for test
1314
expect(t('menu.not.found')).toBe('menu.not.found')
1415
})
1516

@@ -21,7 +22,7 @@ describe('createI18n', () => {
2122

2223
it('should return the correct locale in array', () => {
2324
const { t } = createI18n()
24-
expect(t('quitDialog.buttons.0')).toBe('Quit')
25-
expect(t('quitDialog.buttons.1')).toBe('Cancel')
25+
expect(t('quitDialog.buttons.quit')).toBe('Quit')
26+
expect(t('quitDialog.buttons.cancel')).toBe('Cancel')
2627
})
2728
})

apps/stage-tamagotchi/src/main/locales/index.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ const locales = {
77
'zh-CN': zhCN,
88
}
99

10+
type Message = typeof locales['en-US']
11+
type KeyOfExcludeSymbol<T> = Exclude<keyof T, symbol>
12+
type ValueOf<T> = T[KeyOfExcludeSymbol<T>]
13+
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>}`
14+
type LocalePath = PathOf<Message>
15+
1016
export function createI18n() {
1117
let locale = 'en-US'
1218
let messages = locales['en-US']
1319

14-
function t(key: string) {
20+
function t(key: LocalePath) {
1521
const path = key.split('.')
1622
let current = messages
1723
let result = ''
@@ -44,3 +50,5 @@ export function createI18n() {
4450
locale,
4551
}
4652
}
53+
54+
export type I18n = ReturnType<typeof createI18n>

apps/stage-tamagotchi/src/main/locales/zh-CN.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ export default {
44
quit: '退出',
55
about: '关于',
66
toggleDevTools: '切换开发者工具',
7+
show: '显示 airi',
8+
hide: '隐藏 airi',
79
},
810
quitDialog: {
911
title: '退出',
1012
message: '确定要退出吗?',
11-
buttons: ['退出', '取消'],
13+
buttons: {
14+
quit: '退出',
15+
cancel: '取消',
16+
},
1217
},
1318
}

0 commit comments

Comments
 (0)