feat(stage-tamagotchi): caption overlay UI

This commit is contained in:
Neko Ayaka
2025-10-20 00:30:05 +08:00
parent 5cfc9b0d63
commit 032503e6df
8 changed files with 481 additions and 12 deletions
+20 -4
View File
@@ -17,6 +17,7 @@ import macOSTrayIcon from '../../resources/tray-icon-macos.png?asset'
import { openDebugger, setupDebugger } from './app/debugger'
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed, onAppBeforeQuit } from './libs/bootkit/lifecycle'
import { setElectronMainDirname } from './libs/electron/location'
import { setupCaptionWindowManager } from './windows/caption'
import { setupInlayWindow } from './windows/inlay'
import { setupMainWindow } from './windows/main'
import { setupSettingsWindowReusableFunc } from './windows/settings'
@@ -49,6 +50,7 @@ electronApp.setAppUserModelId('ai.moeru.airi')
function setupTray(params: {
mainWindow: BrowserWindow
settingsWindow: () => Promise<BrowserWindow>
captionWindow: ReturnType<typeof setupCaptionWindowManager>
}): void {
once(() => {
const appTray = new Tray(nativeImage.createFromPath(macOSTrayIcon).resize({ width: 16 }))
@@ -56,9 +58,19 @@ function setupTray(params: {
const contextMenu = Menu.buildFromTemplate([
{ label: 'Show', click: () => toggleWindowShow(params.mainWindow) },
{ label: 'Inlay', click: () => setupInlayWindow() },
{ type: 'separator' },
{ label: 'Settings', click: () => params.settingsWindow().then(window => toggleWindowShow(window)) },
{ label: 'Settings...', click: () => params.settingsWindow().then(window => toggleWindowShow(window)) },
{ type: 'separator' },
{ label: 'Open Inlay...', click: () => setupInlayWindow() },
{ 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' },
{ label: 'Quit', click: () => app.quit() },
])
@@ -117,9 +129,13 @@ app.whenReady().then(async () => {
dependsOn: { settingsWindow },
build: async ({ dependsOn }) => setupMainWindow(dependsOn),
})
const captionWindow = injecta.provide('windows:caption', {
dependsOn: { mainWindow },
build: async ({ dependsOn }) => setupCaptionWindowManager({ mainWindow: dependsOn.mainWindow }),
})
const tray = injecta.provide('app:tray', {
dependsOn: { mainWindow, settingsWindow },
build: async ({ dependsOn }) => setupTray(dependsOn),
dependsOn: { mainWindow, settingsWindow, captionWindow },
build: async ({ dependsOn }) => setupTray(dependsOn as any),
})
injecta.invoke({
dependsOn: { mainWindow, tray },
@@ -0,0 +1,360 @@
import type { BrowserWindow, BrowserWindowConstructorOptions, Rectangle } from 'electron'
import { createHash } from 'node:crypto'
import { join, resolve } from 'node:path'
import { defineInvokeHandler } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/main'
import { animate, utils } from 'animejs'
import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen, shell } from 'electron'
import { debounce, throttle } from 'es-toolkit'
import { isMacOS } from 'std-env'
import icon from '../../../../resources/icon.png?asset'
import { captionAttachedChanged, captionGetAttached } from '../../../shared/eventa'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { mapForBreakpoints, resolutionBreakpoints, widthFrom } from '../shared/display'
import { createConfig } from '../shared/persistence'
import { transparentWindowConfig } from '../shared/window'
interface CaptionMatrixConfig {
bounds: Rectangle
relativeToMain?: { dx: number, dy: number }
}
interface CaptionConfig {
attached: boolean
matrices: Record<string, CaptionMatrixConfig>
}
function computeDisplayMatrixHash(): string {
const displays = screen.getAllDisplays()
const signature = displays
.slice()
.sort((a, b) => (a.bounds.x - b.bounds.x) || (a.bounds.y - b.bounds.y))
.map(d => [d.bounds.x, d.bounds.y, d.bounds.width, d.bounds.height, d.scaleFactor ?? 1].join(','))
.join('|')
return createHash('sha256').update(signature).digest('hex').slice(0, 16)
}
function clampBoundsWithinRect(bounds: Rectangle, rect: Rectangle): Rectangle {
const x = Math.min(Math.max(bounds.x, rect.x), rect.x + rect.width - bounds.width)
const y = Math.min(Math.max(bounds.y, rect.y), rect.y + rect.height - bounds.height)
return { x, y, width: bounds.width, height: bounds.height }
}
function computeInitialCaptionBounds(params: { mainWindow: BrowserWindow, captionOptions?: Partial<Rectangle> }): Rectangle {
const mainBounds = params.mainWindow.getBounds()
const displayWorkArea = screen.getDisplayMatching(mainBounds).workArea
// Base sizing from display width with sensible caps
const width = mapForBreakpoints(
displayWorkArea.width,
{
'720p': widthFrom(displayWorkArea, { percentage: 0.9, max: { actual: 560 }, min: { actual: 280 } }),
'1080p': widthFrom(displayWorkArea, { percentage: 0.5, max: { actual: 640 }, min: { actual: 320 } }),
'2k': widthFrom(displayWorkArea, { percentage: 0.4, max: { actual: 720 }, min: { actual: 360 } }),
'4k': widthFrom(displayWorkArea, { percentage: 0.33, max: { actual: 768 }, min: { actual: 420 } }),
},
{ breakpoints: resolutionBreakpoints },
)
const height = Math.max(Math.floor(width / 3.2), 120)
const margin = 16
// Prefer to the right of main window, else to the left, else bottom centered
let x = mainBounds.x + mainBounds.width + margin
let y = mainBounds.y + mainBounds.height - height
const rightEdge = x + width
const displayRight = displayWorkArea.x + displayWorkArea.width
if (rightEdge > displayRight) {
// Place to the left
x = mainBounds.x - width - margin
}
// If still out of bounds horizontally, fallback to bottom center
if (x < displayWorkArea.x || (x + width) > displayRight) {
x = displayWorkArea.x + Math.floor((displayWorkArea.width - width) / 2)
}
// Clamp vertically
if (y < displayWorkArea.y) {
y = displayWorkArea.y + margin
}
const initial = clampBoundsWithinRect({ x, y, width, height }, displayWorkArea)
return { ...initial, ...params.captionOptions }
}
function createCaptionWindow(options?: BrowserWindowConstructorOptions) {
const window = new ElectronBrowserWindow({
title: 'Caption',
width: 480,
height: 180,
show: false,
icon,
webPreferences: {
preload: join(__dirname, '../preload/index.mjs'),
sandbox: false,
},
// Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation.
//
// https://github.com/electron/electron/issues/10078#issuecomment-3410164802
// https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac
type: 'panel',
...transparentWindowConfig(),
...options,
})
// Click-through is controlled by caller via setIgnoreMouseEvents
// Avoid window buttons on macOS frameless windows
// Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation.
//
// https://github.com/electron/electron/issues/10078#issuecomment-3410164802
// https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac
window.setAlwaysOnTop(true, 'screen-saver', 2)
window.setFullScreenable(false)
window.setVisibleOnAllWorkspaces(true)
if (isMacOS) {
window.setWindowButtonVisibility(false)
}
window.on('ready-to-show', () => window.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
return window
}
export function setupCaptionWindowManager(params: { mainWindow: BrowserWindow }) {
const matrixHash = computeDisplayMatrixHash()
const {
setup: setupConfig,
get: getConfig,
update: updateConfig,
} = createConfig<CaptionConfig>('windows-caption', 'config.json', { default: { attached: true, matrices: {} } })
setupConfig()
let isAttached = getConfig()?.attached ?? true
let lastProgrammaticMoveAt = 0
// Keep references to listeners so we can detach when toggling
let detachMainMoveListener: (() => void) | undefined
// Note: when attaching, we compute and persist the current relative offset
// and start following without docking, so no immediate reposition is needed here.
function computeRelativeOffset(win: BrowserWindow): { dx: number, dy: number } {
const caption = win.getBounds()
const main = params.mainWindow.getBounds()
return { dx: caption.x - main.x, dy: caption.y - main.y }
}
function attachToMain(win: BrowserWindow) {
const cfg = getConfig() ?? { attached: isAttached, matrices: {} }
const initialOffset = cfg?.matrices?.[matrixHash]?.relativeToMain ?? computeRelativeOffset(win)
// Store relative offset for this matrix
const cfgToSave = getConfig() ?? { attached: isAttached, matrices: {} }
cfgToSave.matrices[matrixHash] = { ...(cfgToSave.matrices[matrixHash] ?? {}), relativeToMain: initialOffset }
updateConfig(cfgToSave)
let anim: ReturnType<typeof animate> | null = null
const state = { x: 0, y: 0 }
const settleTo = (toX: number, toY: number) => {
if (win.isDestroyed())
return
const b = win.getBounds()
state.x = b.x
state.y = b.y
anim?.pause()
anim = animate(state, {
x: toX,
y: toY,
duration: 160,
ease: 'outCubic',
modifier: utils.round(0),
onRender: () => {
if (win.isDestroyed())
return
lastProgrammaticMoveAt = Date.now()
win.setPosition(state.x, state.y)
},
})
}
let lastTx = 0
let lastTy = 0
let lastAppliedTx = Number.NaN
let lastAppliedTy = Number.NaN
const moveThrottled = throttle(() => {
const stored = getConfig()?.matrices[matrixHash]?.relativeToMain ?? initialOffset
const main = params.mainWindow.getBounds()
const b = win.getBounds()
let tx = main.x + stored.dx
let ty = main.y + stored.dy
const target = { x: tx, y: ty, width: b.width, height: b.height }
const workArea = screen.getDisplayMatching(target).workArea
const clamped = clampBoundsWithinRect(target, workArea)
tx = clamped.x
ty = clamped.y
lastTx = tx
lastTy = ty
if (Math.abs(lastAppliedTx - tx) <= 0 && Math.abs(lastAppliedTy - ty) <= 0)
return
lastAppliedTx = tx
lastAppliedTy = ty
// Animate towards target at throttled cadence for visible easing
settleTo(tx, ty)
}, 1000 / 30)
const settleDebounced = debounce(() => {
settleTo(lastTx, lastTy)
}, 200)
const onMainChange = () => { moveThrottled(); settleDebounced() }
params.mainWindow.on('move', onMainChange)
params.mainWindow.on('resize', onMainChange)
// Initialize target once
onMainChange()
detachMainMoveListener = () => {
params.mainWindow.removeListener('move', onMainChange)
params.mainWindow.removeListener('resize', onMainChange)
anim?.pause()
anim = null
}
}
function detachFromMain() {
detachMainMoveListener?.()
detachMainMoveListener = undefined
}
let eventaContext: ReturnType<typeof createContext>['context'] | undefined
const reusable = createReusableWindow(async () => {
const window = createCaptionWindow()
const { context } = createContext(ipcMain, window)
eventaContext = context
const cfg = getConfig()
const saved = cfg?.matrices?.[matrixHash]?.bounds
if (saved) {
const workArea = screen.getDisplayMatching(saved).workArea
const clamped = clampBoundsWithinRect(saved, workArea)
window.setBounds(clamped)
}
else {
const initialBounds = computeInitialCaptionBounds({ mainWindow: params.mainWindow })
window.setBounds(initialBounds)
}
const persistBounds = () => {
const config = getConfig() ?? { attached: isAttached, matrices: {} }
const b = window.getBounds()
config.matrices[matrixHash] = { ...(config.matrices[matrixHash] ?? {}), bounds: b }
config.attached = isAttached
if (isAttached && Date.now() - lastProgrammaticMoveAt > 100) {
const rel = computeRelativeOffset(window)
config.matrices[matrixHash] = { ...(config.matrices[matrixHash] ?? {}), bounds: b, relativeToMain: rel }
}
updateConfig(config)
}
window.on('resize', persistBounds)
window.on('move', persistBounds)
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/caption'))
const cleanupGetAttached = defineInvokeHandler(context, captionGetAttached, async () => isAttached)
try { context.emit(captionAttachedChanged, isAttached) }
catch {}
if (isAttached) {
attachToMain(window)
}
window.on('closed', () => {
detachFromMain()
try { cleanupGetAttached() }
catch {}
eventaContext = undefined
})
return window
})
async function getWindow(): Promise<BrowserWindow> { return reusable.getWindow() }
async function setFollowWindow(attached: boolean) {
isAttached = attached
const window = await reusable.getWindow()
if (isAttached) {
// Compute and persist current relative offset based on existing positions
const rel = computeRelativeOffset(window)
const cfg = getConfig() ?? { attached: isAttached, matrices: {} }
cfg.matrices[matrixHash] = { ...(cfg.matrices[matrixHash] ?? {}), relativeToMain: rel }
updateConfig(cfg)
// Start following main without re-docking; keep current position
attachToMain(window)
}
else {
detachFromMain()
}
const config = getConfig() ?? { attached: isAttached, matrices: {} }
config.attached = isAttached
updateConfig(config)
// Keep window visible after toggle
window.show()
// Notify renderer for UI state (handle visibility)
try { eventaContext?.emit(captionAttachedChanged, isAttached) }
catch {}
}
async function toggleFollowWindow() {
await setFollowWindow(!isAttached)
}
function getIsFollowingWindow(): boolean { return isAttached }
async function resetToSide() {
const window = await reusable.getWindow()
// Prevent user-move persistence from overwriting our programmatic move
lastProgrammaticMoveAt = Date.now()
const initialBounds = computeInitialCaptionBounds({ mainWindow: params.mainWindow })
window.setBounds(initialBounds)
// Persist new bounds and a clean relative offset so follow uses it
const config = getConfig() ?? { attached: isAttached, matrices: {} }
const b = window.getBounds()
const rel = computeRelativeOffset(window)
config.matrices[matrixHash] = { ...(config.matrices[matrixHash] ?? {}), bounds: b, relativeToMain: rel }
config.attached = isAttached
updateConfig(config)
}
return {
getWindow,
setFollowWindow,
toggleFollowWindow,
getIsFollowingWindow,
resetToSide,
}
}
@@ -50,6 +50,11 @@ export async function setupMainWindow(params: {
preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'),
sandbox: false,
},
// Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation.
//
// https://github.com/electron/electron/issues/10078#issuecomment-3410164802
// https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac
type: 'panel',
...transparentWindowConfig(),
})
@@ -98,7 +103,13 @@ export async function setupMainWindow(params: {
window.on('resize', () => handleNewBounds(window.getBounds()))
window.on('move', () => handleNewBounds(window.getBounds()))
window.setAlwaysOnTop(true)
// Thanks to [@HeartArmy](https://github.com/HeartArmy) for the tip implementation.
//
// https://github.com/electron/electron/issues/10078#issuecomment-3410164802
// https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac
window.setAlwaysOnTop(true, 'screen-saver', 1)
window.setFullScreenable(false)
window.setVisibleOnAllWorkspaces(true)
if (isMacOS) {
window.setWindowButtonVisibility(false)
}
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { defineInvoke } from '@unbird/eventa'
import { createContext } from '@unbird/eventa/adapters/electron/renderer'
import { onMounted, ref } from 'vue'
import { captionAttachedChanged, captionGetAttached } from '../../shared/caption'
const attached = ref(true)
const { context } = createContext(window.electron.ipcRenderer)
const getAttached = defineInvoke(context, captionGetAttached)
onMounted(async () => {
try {
const isAttached = await getAttached()
attached.value = Boolean(isAttached)
}
catch {}
try {
context.on(captionAttachedChanged, (event) => {
attached.value = Boolean(event?.body)
})
}
catch {}
})
</script>
<template>
<div class="pointer-events-none h-full w-full flex items-end justify-center">
<div class="pointer-events-auto relative select-none rounded-xl bg-primary-950/10 px-3 py-2 shadow-md backdrop-blur-md dark:bg-neutral-900/70">
<div
v-show="!attached"
class="[-webkit-app-region:drag] absolute left-1/2 h-[14px] w-[36px] border border-[rgba(125,125,125,0.35)] rounded-[10px] bg-[rgba(125,125,125,0.28)] backdrop-blur-[6px] -top-2 -translate-x-1/2"
title="Drag to move"
>
<div class="absolute left-1/2 top-1/2 h-[3px] w-4 rounded-full bg-[rgba(255,255,255,0.85)] -translate-x-1/2 -translate-y-1/2" />
</div>
<div
class="content text-primary-50 tracking-widest font-cute text-stroke-4 text-stroke-primary-300/50 text-shadow-lg text-shadow-color-primary-700/50"
:style="{ paintOrder: 'stroke fill', fontSize: '2rem' }"
>
This is a test message caption overlay.
</div>
</div>
</div>
</template>
<style scoped>
</style>
<route lang="yaml">
meta:
layout: stage
</route>
+8 -6
View File
@@ -1,9 +1,11 @@
import { defineInvokeEventa } from '@unbird/eventa'
import { defineEventa, defineInvokeEventa } from '@unbird/eventa'
export const electronStartTrackMousePosition = defineInvokeEventa('electron:eventa:invoke:start-tracking-mouse-position')
export const electronStartDraggingWindow = defineInvokeEventa('electron:eventa:invoke:start-dragging-window')
export const electronOpenMainDevtools = defineInvokeEventa('electron:eventa:invoke:windows:main:devtools:open')
export const electronOpenSettings = defineInvokeEventa('electron:eventa:invoke:windows:settings:open')
export const electronOpenSettingsDevtools = defineInvokeEventa('electron:eventa:invoke:windows:settings:devtools:open')
export const electronStartTrackMousePosition = defineInvokeEventa('eventa:invoke:electron:start-tracking-mouse-position')
export const electronStartDraggingWindow = defineInvokeEventa('eventa:invoke:electron:start-dragging-window')
export const electronOpenMainDevtools = defineInvokeEventa('eventa:invoke:electron:windows:main:devtools:open')
export const electronOpenSettings = defineInvokeEventa('eventa:invoke:electron:windows:settings:open')
export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open')
export const captionAttachedChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:attached-changed')
export const captionGetAttached = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-attached')
export { electron } from './electron'
+3 -1
View File
@@ -35,6 +35,8 @@
"src/shared/**/*.ts",
"src/shared/**/*.d.ts",
"src/shared/**/*.tsx",
"src/shared/**/*.vue"
"src/shared/**/*.vue",
"../../uno.config.ts"
]
}
+22
View File
@@ -0,0 +1,22 @@
import { createLocalFontProcessor } from '@unocss/preset-web-fonts/local'
import { defineConfig, mergeConfigs, presetWebFonts } from 'unocss'
import { presetWebFontsFonts, sharedUnoConfig } from '../../uno.config'
export default mergeConfigs([
sharedUnoConfig(),
defineConfig({
presets: [
presetWebFonts({
fonts: {
...presetWebFontsFonts('none'),
},
timeouts: {
warning: 5000,
failure: 10000,
},
processors: createLocalFontProcessor(),
}),
],
}),
])
+1
View File
@@ -230,6 +230,7 @@ words:
- saccades
- safetensors
- SAVEPOINT
- Screenable
- sensenova
- serde
- Shadcn