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
This commit is contained in:
LemonNeko
2025-02-23 22:54:24 +08:00
committed by GitHub
parent a15457a50d
commit 636371e971
16 changed files with 271 additions and 210 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -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",
+61 -195
View File
@@ -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
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
}
@@ -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',
},
},
}
@@ -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')
})
})
@@ -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 = ''
@@ -44,3 +50,5 @@ export function createI18n() {
locale,
}
}
export type I18n = ReturnType<typeof createI18n>
@@ -4,10 +4,15 @@ export default {
quit: '退出',
about: '关于',
toggleDevTools: '切换开发者工具',
show: '显示 airi',
hide: '隐藏 airi',
},
quitDialog: {
title: '退出',
message: '确定要退出吗?',
buttons: ['退出', '取消'],
buttons: {
quit: '退出',
cancel: '取消',
},
},
}
+65
View File
@@ -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)
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores'
import { watch } from 'vue'
import { onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
@@ -11,6 +11,11 @@ watch(() => settings.language, (language) => {
i18n.locale.value = language
window.electron.ipcRenderer.send('locale-changed', language)
})
// FIXME: store settings to file
onMounted(() => {
window.electron.ipcRenderer.send('locale-changed', settings.language)
})
</script>
<template>
@@ -34,7 +34,7 @@ onTokenLiteral(async () => {
<template>
<div py="1" flex="~ col" rounded="lg" overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate h-full w-full max-h="30vh" flex="~ col" overflow-scroll>
<div ref="chatHistoryRef" v-auto-animate max-h="30vh" flex="~ col" scrollbar="~ w-2 track-color-transparent thumb-color-pink-300 rounded" h-full w-full overflow-scroll>
<div flex-1 /> <!-- spacer -->
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'assistant'" flex mr="12">
@@ -89,7 +89,7 @@ onMounted(() => {
<template>
<div>
<div relative w-full flex gap-1>
<TamagotchiChatHistory absolute left-0 top-0 transform="translate-y-[-100%]" w-full />
<TamagotchiChatHistory transform="translate-y-[-100%]" absolute left-0 top-0 w-full />
<div flex flex-1>
<BasicTextarea
v-model="messageInput"
@@ -60,7 +60,7 @@ const modeIndicatorClass = computed(() => {
>
<div
v-if="windowStore.controlMode === WindowControlMode.MOVE"
class="drag-region absolute left-0 top-0 z-999 h-full w-full flex items-center justify-center"
class="drag-region absolute left-0 top-0 z-999 h-full w-full flex items-center justify-center overflow-hidden"
>
<div class="absolute h-32 w-full flex items-center justify-center b-2 b-pink bg-white">
<div class="wall absolute top-0 h-8" />
+2
View File
@@ -9,6 +9,7 @@ import {
transformerDirectives,
transformerVariantGroup,
} from 'unocss'
import { presetScrollbar } from 'unocss-preset-scrollbar'
export default defineConfig({
presets: [
@@ -30,6 +31,7 @@ export default defineConfig({
...createExternalPackageIconLoader('@proj-airi/lobe-icons'),
},
}),
presetScrollbar(),
],
transformers: [
transformerDirectives(),