feat(stage-tamagotchi): basic widget window manager, moved partial code from component-calling
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import type { WidgetsWindowManager } from './windows/widgets'
|
||||
|
||||
import { platform } from 'node:process'
|
||||
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
@@ -22,6 +24,7 @@ import { setupInlayWindow } from './windows/inlay'
|
||||
import { setupMainWindow } from './windows/main'
|
||||
import { setupSettingsWindowReusableFunc } from './windows/settings'
|
||||
import { toggleWindowShow } from './windows/shared/window'
|
||||
import { setupWidgetsWindowManager } from './windows/widgets'
|
||||
|
||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
||||
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
|
||||
@@ -56,6 +59,7 @@ function setupTray(params: {
|
||||
mainWindow: BrowserWindow
|
||||
settingsWindow: () => Promise<BrowserWindow>
|
||||
captionWindow: ReturnType<typeof setupCaptionWindowManager>
|
||||
widgetsWindow: WidgetsWindowManager
|
||||
}): void {
|
||||
once(() => {
|
||||
const trayImage = nativeImage.createFromPath(isMacOS ? macOSTrayIcon : icon).resize({ width: 16 })
|
||||
@@ -69,6 +73,7 @@ function setupTray(params: {
|
||||
{ label: 'Settings...', click: () => params.settingsWindow().then(window => toggleWindowShow(window)) },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Open Inlay...', click: () => setupInlayWindow() },
|
||||
{ label: 'Open Widgets...', click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) },
|
||||
{ label: 'Open Caption...', click: () => params.captionWindow.getWindow().then(window => toggleWindowShow(window)) },
|
||||
{
|
||||
type: 'submenu',
|
||||
@@ -96,10 +101,15 @@ app.whenReady().then(async () => {
|
||||
injecta.setLogger(createLoggLogger(useLogg('injecta').useGlobalConfig()))
|
||||
|
||||
const channelServerModule = injecta.provide('modules:channel-server', async () => setupChannelServer())
|
||||
const settingsWindow = injecta.provide('windows:settings', () => setupSettingsWindowReusableFunc())
|
||||
const chatWindow = injecta.provide('windows:chat', { build: () => setupChatWindowReusableFunc() })
|
||||
const widgetsManager = injecta.provide('windows:widgets', { build: () => setupWidgetsWindowManager() })
|
||||
|
||||
const settingsWindow = injecta.provide('windows:settings', {
|
||||
dependsOn: { widgetsManager },
|
||||
build: ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
|
||||
})
|
||||
const mainWindow = injecta.provide('windows:main', {
|
||||
dependsOn: { settingsWindow, chatWindow },
|
||||
dependsOn: { settingsWindow, chatWindow, widgetsManager },
|
||||
build: async ({ dependsOn }) => setupMainWindow(dependsOn),
|
||||
})
|
||||
const captionWindow = injecta.provide('windows:caption', {
|
||||
@@ -107,7 +117,7 @@ app.whenReady().then(async () => {
|
||||
build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn),
|
||||
})
|
||||
const tray = injecta.provide('app:tray', {
|
||||
dependsOn: { mainWindow, settingsWindow, captionWindow },
|
||||
dependsOn: { mainWindow, settingsWindow, captionWindow, widgetsWindow: widgetsManager },
|
||||
build: async ({ dependsOn }) => setupTray(dependsOn),
|
||||
})
|
||||
injecta.invoke({
|
||||
|
||||
@@ -2,19 +2,34 @@ import type { BrowserWindow } from 'electron'
|
||||
|
||||
export function createReusableWindow(setupFn: () => BrowserWindow | Promise<BrowserWindow>): { getWindow: () => Promise<BrowserWindow> } {
|
||||
let window: BrowserWindow | undefined
|
||||
let windowSetupFnPromise: Promise<BrowserWindow> | undefined
|
||||
|
||||
const ensureWindow = async () => {
|
||||
if (window && !window.isDestroyed())
|
||||
return window
|
||||
|
||||
if (windowSetupFnPromise)
|
||||
return windowSetupFnPromise
|
||||
|
||||
windowSetupFnPromise = Promise.resolve(setupFn()).then((created) => {
|
||||
window = created
|
||||
windowSetupFnPromise = undefined
|
||||
|
||||
created.on?.('closed', () => {
|
||||
if (window === created)
|
||||
window = undefined
|
||||
})
|
||||
|
||||
return created
|
||||
}).catch((error) => {
|
||||
windowSetupFnPromise = undefined
|
||||
throw error
|
||||
})
|
||||
|
||||
return windowSetupFnPromise
|
||||
}
|
||||
|
||||
return {
|
||||
getWindow: async () => {
|
||||
if (!window) {
|
||||
window = await setupFn()
|
||||
return window
|
||||
}
|
||||
if (window.isDestroyed()) {
|
||||
window = await setupFn()
|
||||
return window
|
||||
}
|
||||
|
||||
return window
|
||||
},
|
||||
getWindow: async () => ensureWindow(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { createContext } from '@unbird/eventa/adapters/electron/main'
|
||||
import type { BrowserWindow, IpcMainEvent } from 'electron'
|
||||
|
||||
import type { WidgetsWindowManager } from '../../../windows/widgets'
|
||||
|
||||
import { defineInvokeHandlers } from '@unbird/eventa'
|
||||
|
||||
import { widgetsAdd, widgetsClear, widgetsFetch, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../../shared/eventa'
|
||||
|
||||
interface InvokeOptions {
|
||||
raw?: { ipcMainEvent?: IpcMainEvent }
|
||||
}
|
||||
|
||||
function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) {
|
||||
const sender = options?.raw?.ipcMainEvent?.sender
|
||||
if (!sender)
|
||||
return false
|
||||
return sender.id === window.webContents.id
|
||||
}
|
||||
|
||||
export function createWidgetsService(params: { context: ReturnType<typeof createContext>['context'], widgetsManager: WidgetsWindowManager, window: BrowserWindow }) {
|
||||
defineInvokeHandlers(params.context, {
|
||||
widgetsPrepareWindow,
|
||||
widgetsOpenWindow,
|
||||
widgetsAdd,
|
||||
widgetsUpdate,
|
||||
widgetsRemove,
|
||||
widgetsClear,
|
||||
widgetsFetch,
|
||||
}, {
|
||||
widgetsPrepareWindow: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return params.widgetsManager!.prepareWidgetWindow(payload ?? undefined)
|
||||
},
|
||||
widgetsOpenWindow: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return params.widgetsManager!.openWindow(payload ?? undefined)
|
||||
},
|
||||
widgetsAdd: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return payload ? params.widgetsManager!.pushWidget(payload) : undefined
|
||||
},
|
||||
widgetsUpdate: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return payload ? params.widgetsManager!.updateWidget(payload) : undefined
|
||||
},
|
||||
widgetsRemove: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return payload?.id ? params.widgetsManager!.removeWidget(payload.id) : undefined
|
||||
},
|
||||
widgetsClear: async (_payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return params.widgetsManager!.clearWidgets()
|
||||
},
|
||||
widgetsFetch: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return payload?.id ? params.widgetsManager!.getWidgetSnapshot(payload.id) : undefined
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BrowserWindowConstructorOptions, Rectangle } from 'electron'
|
||||
|
||||
import type { WidgetsWindowManager } from '../widgets'
|
||||
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { env } from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -28,6 +30,7 @@ interface AppConfig {
|
||||
export async function setupMainWindow(params: {
|
||||
settingsWindow: () => Promise<BrowserWindow>
|
||||
chatWindow: () => Promise<BrowserWindow>
|
||||
widgetsManager: WidgetsWindowManager
|
||||
}) {
|
||||
const {
|
||||
setup: setupConfig,
|
||||
@@ -123,7 +126,12 @@ export async function setupMainWindow(params: {
|
||||
|
||||
await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')))
|
||||
|
||||
setupMainWindowElectronInvokes({ window, settingsWindow: params.settingsWindow, chatWindow: params.chatWindow })
|
||||
setupMainWindowElectronInvokes({
|
||||
window,
|
||||
settingsWindow: params.settingsWindow,
|
||||
chatWindow: params.chatWindow,
|
||||
widgetsManager: params.widgetsManager,
|
||||
})
|
||||
|
||||
/**
|
||||
* This is a know issue (or expected behavior maybe) to Electron.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import type { WidgetsWindowManager } from '../../widgets'
|
||||
|
||||
import { defineInvokeHandler } from '@unbird/eventa'
|
||||
import { createContext } from '@unbird/eventa/adapters/electron/main'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings } from '../../../../shared/eventa'
|
||||
import { createWidgetsService } from '../../../services/airi/widgets'
|
||||
import { createScreenService, createWindowService } from '../../../services/electron'
|
||||
import { toggleWindowShow } from '../../shared'
|
||||
|
||||
@@ -12,6 +15,7 @@ export function setupMainWindowElectronInvokes(params: {
|
||||
window: BrowserWindow
|
||||
settingsWindow: () => Promise<BrowserWindow>
|
||||
chatWindow: () => Promise<BrowserWindow>
|
||||
widgetsManager: WidgetsWindowManager
|
||||
}) {
|
||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
||||
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
|
||||
@@ -22,6 +26,7 @@ export function setupMainWindowElectronInvokes(params: {
|
||||
|
||||
createScreenService({ context, window: params.window })
|
||||
createWindowService({ context, window: params.window })
|
||||
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window })
|
||||
|
||||
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
|
||||
defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow()))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { WidgetsWindowManager } from '../widgets'
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
import { BrowserWindow, shell } from 'electron'
|
||||
@@ -8,7 +10,9 @@ import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs
|
||||
import { createReusableWindow } from '../../libs/electron/window-manager'
|
||||
import { setupSettingsWindowInvokes } from './rpc/index.electron'
|
||||
|
||||
export function setupSettingsWindowReusableFunc() {
|
||||
export function setupSettingsWindowReusableFunc(params: {
|
||||
widgetsManager: WidgetsWindowManager
|
||||
}) {
|
||||
return createReusableWindow(async () => {
|
||||
const window = new BrowserWindow({
|
||||
title: 'Settings',
|
||||
@@ -29,7 +33,7 @@ export function setupSettingsWindowReusableFunc() {
|
||||
})
|
||||
|
||||
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/settings'))
|
||||
await setupSettingsWindowInvokes({ settingsWindow: window })
|
||||
await setupSettingsWindowInvokes({ settingsWindow: window, widgetsManager: params.widgetsManager })
|
||||
|
||||
return window
|
||||
}).getWindow
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import type { WidgetsWindowManager } from '../../widgets'
|
||||
|
||||
import { defineInvokeHandler } from '@unbird/eventa'
|
||||
import { createContext } from '@unbird/eventa/adapters/electron/main'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { electronOpenSettingsDevtools } from '../../../../shared/eventa'
|
||||
import { createWidgetsService } from '../../../services/airi/widgets'
|
||||
import { createScreenService, createWindowService } from '../../../services/electron'
|
||||
|
||||
export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow }) {
|
||||
export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow, widgetsManager: WidgetsWindowManager }) {
|
||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
||||
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
|
||||
// manage events within eventa's context system.
|
||||
@@ -17,6 +20,7 @@ export async function setupSettingsWindowInvokes(params: { settingsWindow: Brows
|
||||
|
||||
createScreenService({ context, window: params.settingsWindow })
|
||||
createWindowService({ context, window: params.settingsWindow })
|
||||
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow })
|
||||
|
||||
defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { BrowserWindow, Rectangle } from 'electron'
|
||||
|
||||
import type { WidgetsAddPayload, WidgetSnapshot } from '../../../shared/eventa'
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
import { createContext } from '@unbird/eventa/adapters/electron/main'
|
||||
import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen, shell } from 'electron'
|
||||
import { isMacOS } from 'std-env'
|
||||
|
||||
import icon from '../../../../resources/icon.png?asset'
|
||||
|
||||
import { widgetsClearEvent, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../../shared/eventa'
|
||||
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
|
||||
import { createReusableWindow } from '../../libs/electron/window-manager'
|
||||
import { createConfig } from '../shared/persistence'
|
||||
import { transparentWindowConfig } from '../shared/window'
|
||||
import { setupWidgetsWindowInvokes } from './rpc/index.electron'
|
||||
|
||||
export interface WidgetsWindowManager {
|
||||
getWindow: () => Promise<BrowserWindow>
|
||||
openWindow: (params?: { id?: string }) => Promise<void>
|
||||
pushWidget: (payload: WidgetsAddPayload) => Promise<string>
|
||||
updateWidget: (payload: { id: string, componentProps?: Record<string, any> }) => Promise<void>
|
||||
removeWidget: (id: string) => Promise<void>
|
||||
clearWidgets: () => Promise<void>
|
||||
getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined
|
||||
prepareWidgetWindow: (options?: { id?: string }) => string
|
||||
}
|
||||
|
||||
interface WidgetsWindowConfig {
|
||||
bounds?: Rectangle
|
||||
}
|
||||
|
||||
function computeDefaultBounds(): Rectangle {
|
||||
const primary = screen.getPrimaryDisplay().workArea
|
||||
const width = Math.min(500, Math.floor(primary.width * 0.35))
|
||||
const height = Math.min(500, Math.floor(primary.height * 0.6))
|
||||
const x = primary.x + primary.width - width - 16
|
||||
const y = primary.y + 16
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
function createWidgetsWindow() {
|
||||
const window = new ElectronBrowserWindow({
|
||||
title: 'Widgets',
|
||||
width: 620,
|
||||
height: 760,
|
||||
show: false,
|
||||
icon,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.mjs'),
|
||||
sandbox: false,
|
||||
},
|
||||
// Top-level overlay style like other overlay windows
|
||||
type: 'panel',
|
||||
...transparentWindowConfig(),
|
||||
})
|
||||
|
||||
// Keep on top like caption/main overlays
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1)
|
||||
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
|
||||
}
|
||||
|
||||
interface WidgetRecord extends WidgetSnapshot {
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface WidgetWindowContext {
|
||||
widgetId: string
|
||||
windowBuilder: () => Promise<BrowserWindow>
|
||||
window?: BrowserWindow
|
||||
}
|
||||
|
||||
export function setupWidgetsWindowManager(): WidgetsWindowManager {
|
||||
const { setup, get, update } = createConfig<WidgetsWindowConfig>('windows-widgets', 'config.json', { default: {} })
|
||||
setup()
|
||||
|
||||
let eventaContext: ReturnType<typeof createContext>['context'] | undefined
|
||||
const widgetRecords = new Map<string, WidgetRecord>()
|
||||
const windowContexts = new Map<string, WidgetWindowContext>()
|
||||
|
||||
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
|
||||
const defaultRoute = '/widgets'
|
||||
|
||||
let pendingRoute: string | undefined
|
||||
let currentRoute: string | undefined
|
||||
|
||||
let widgetsManager: WidgetsWindowManager | undefined
|
||||
|
||||
const reusable = createReusableWindow(async () => {
|
||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
||||
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
|
||||
// manage events within eventa's context system.
|
||||
ipcMain.setMaxListeners(0)
|
||||
|
||||
const window = createWidgetsWindow()
|
||||
const { context } = createContext(ipcMain, window)
|
||||
eventaContext = context
|
||||
|
||||
const saved = get()?.bounds
|
||||
if (saved) {
|
||||
const work = screen.getDisplayMatching(saved).workArea
|
||||
const clamped: Rectangle = {
|
||||
x: Math.min(Math.max(saved.x, work.x), work.x + work.width - saved.width),
|
||||
y: Math.min(Math.max(saved.y, work.y), work.y + work.height - saved.height),
|
||||
width: Math.min(saved.width, work.width),
|
||||
height: Math.min(saved.height, work.height),
|
||||
}
|
||||
window.setBounds(clamped)
|
||||
}
|
||||
else {
|
||||
window.setBounds(computeDefaultBounds())
|
||||
}
|
||||
|
||||
const persist = () => update({ bounds: window.getBounds() })
|
||||
window.on('resize', persist)
|
||||
window.on('move', persist)
|
||||
|
||||
const initialRoute = pendingRoute ?? defaultRoute
|
||||
await loadWithRoute(window, initialRoute)
|
||||
await setupWidgetsWindowInvokes({ widgetWindow: window, widgetsManager: widgetsManager! })
|
||||
pendingRoute = undefined
|
||||
|
||||
window.on('closed', () => {
|
||||
eventaContext = undefined
|
||||
currentRoute = undefined
|
||||
windowContexts.forEach((context) => {
|
||||
if (context.window === window)
|
||||
context.window = undefined
|
||||
})
|
||||
})
|
||||
return window
|
||||
})
|
||||
|
||||
function prepareWidgetWindow(options?: { id?: string }): string {
|
||||
const id = options?.id ?? Math.random().toString(36).slice(2, 10)
|
||||
if (!windowContexts.has(id)) {
|
||||
windowContexts.set(id, {
|
||||
widgetId: id,
|
||||
windowBuilder: () => getWindow(),
|
||||
window: undefined,
|
||||
})
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
function toSnapshot(record: WidgetRecord): WidgetSnapshot {
|
||||
const { timer, ...snapshot } = record
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function upsertRecord(snapshot: WidgetSnapshot) {
|
||||
const existing = widgetRecords.get(snapshot.id)
|
||||
if (existing?.timer)
|
||||
clearTimeout(existing.timer)
|
||||
|
||||
const record: WidgetRecord = { ...snapshot }
|
||||
|
||||
if (snapshot.ttlMs > 0) {
|
||||
record.timer = setTimeout(() => removeWidgetInternal(snapshot.id), snapshot.ttlMs)
|
||||
}
|
||||
|
||||
widgetRecords.set(snapshot.id, record)
|
||||
}
|
||||
|
||||
function removeWidgetInternal(id: string, emitEvent = true) {
|
||||
const existing = widgetRecords.get(id)
|
||||
if (!existing)
|
||||
return
|
||||
|
||||
if (existing.timer)
|
||||
clearTimeout(existing.timer)
|
||||
|
||||
widgetRecords.delete(id)
|
||||
windowContexts.delete(id)
|
||||
|
||||
if (emitEvent) {
|
||||
try { eventaContext?.emit(widgetsRemoveEvent, { id }) }
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWithRoute(window: BrowserWindow, route: string) {
|
||||
await load(window, withHashRoute(rendererBase, route))
|
||||
currentRoute = route
|
||||
}
|
||||
|
||||
async function getWindowFromContext(context?: WidgetWindowContext): Promise<BrowserWindow> {
|
||||
if (!context)
|
||||
return getWindow()
|
||||
if (context.window && !context.window.isDestroyed())
|
||||
return context.window
|
||||
const resolved = await context.windowBuilder()
|
||||
context.window = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function showWindowWithRoute(route: string, context?: WidgetWindowContext) {
|
||||
pendingRoute = route
|
||||
const window = await getWindowFromContext(context)
|
||||
pendingRoute = undefined
|
||||
if (currentRoute !== route)
|
||||
await loadWithRoute(window, route)
|
||||
window.show()
|
||||
if (context)
|
||||
context.window = window
|
||||
return window
|
||||
}
|
||||
|
||||
async function getWindow(): Promise<BrowserWindow> {
|
||||
return reusable.getWindow()
|
||||
}
|
||||
|
||||
async function openWindow(params?: { id?: string }) {
|
||||
const id = params?.id ? prepareWidgetWindow({ id: params.id }) : undefined
|
||||
const route = id ? `${defaultRoute}?id=${id}` : defaultRoute
|
||||
const context = id ? windowContexts.get(id) : undefined
|
||||
await showWindowWithRoute(route, context)
|
||||
}
|
||||
|
||||
async function pushWidget(payload: WidgetsAddPayload): Promise<string> {
|
||||
const id = prepareWidgetWindow({ id: payload.id })
|
||||
const snapshot: WidgetSnapshot = {
|
||||
id,
|
||||
componentName: payload.componentName,
|
||||
componentProps: payload.componentProps ?? {},
|
||||
size: payload.size ?? 'm',
|
||||
ttlMs: payload.ttlMs ?? 0,
|
||||
}
|
||||
upsertRecord(snapshot)
|
||||
const context = windowContexts.get(id)
|
||||
await showWindowWithRoute(`${defaultRoute}?id=${id}`, context)
|
||||
try { eventaContext?.emit(widgetsRenderEvent, snapshot) }
|
||||
catch {}
|
||||
return id
|
||||
}
|
||||
|
||||
async function updateWidget(payload: { id: string, componentProps?: Record<string, any> }) {
|
||||
if (!payload?.id)
|
||||
return
|
||||
|
||||
const existing = widgetRecords.get(payload.id)
|
||||
if (!existing)
|
||||
return
|
||||
|
||||
const nextSnapshot: WidgetSnapshot = {
|
||||
...toSnapshot(existing),
|
||||
componentProps: payload.componentProps ?? existing.componentProps,
|
||||
}
|
||||
|
||||
upsertRecord(nextSnapshot)
|
||||
|
||||
try { eventaContext?.emit(widgetsUpdateEvent, { id: nextSnapshot.id, componentProps: nextSnapshot.componentProps }) }
|
||||
catch {}
|
||||
}
|
||||
|
||||
async function removeWidget(id: string) {
|
||||
if (!id)
|
||||
return
|
||||
removeWidgetInternal(id, false)
|
||||
try { eventaContext?.emit(widgetsRemoveEvent, { id }) }
|
||||
catch {}
|
||||
}
|
||||
|
||||
async function clearWidgets() {
|
||||
const ids = [...widgetRecords.keys()]
|
||||
for (const id of ids)
|
||||
removeWidgetInternal(id, false)
|
||||
|
||||
try { eventaContext?.emit(widgetsClearEvent, undefined) }
|
||||
catch {}
|
||||
windowContexts.clear()
|
||||
}
|
||||
|
||||
function getWidgetSnapshot(id: string) {
|
||||
const record = widgetRecords.get(id)
|
||||
if (!record)
|
||||
return undefined
|
||||
return toSnapshot(record)
|
||||
}
|
||||
|
||||
widgetsManager = {
|
||||
getWindow,
|
||||
openWindow,
|
||||
pushWidget,
|
||||
updateWidget,
|
||||
removeWidget,
|
||||
clearWidgets,
|
||||
getWidgetSnapshot,
|
||||
prepareWidgetWindow,
|
||||
}
|
||||
|
||||
return widgetsManager!
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import type { WidgetsWindowManager } from '../../widgets'
|
||||
|
||||
import { createContext } from '@unbird/eventa/adapters/electron/main'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { createWidgetsService } from '../../../services/airi/widgets'
|
||||
import { createScreenService, createWindowService } from '../../../services/electron'
|
||||
|
||||
export async function setupWidgetsWindowInvokes(params: { widgetWindow: BrowserWindow, widgetsManager: WidgetsWindowManager }) {
|
||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
||||
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
|
||||
// manage events within eventa's context system.
|
||||
ipcMain.setMaxListeners(0)
|
||||
|
||||
const { context } = createContext(ipcMain, params.widgetWindow)
|
||||
|
||||
createScreenService({ context, window: params.widgetWindow })
|
||||
createWindowService({ context, window: params.widgetWindow })
|
||||
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.widgetWindow })
|
||||
}
|
||||
@@ -108,6 +108,10 @@ const routeHeaderMetadataMap = computed(() => {
|
||||
subtitle: t('settings.title'),
|
||||
title: t('settings.pages.modules.mcp-server.title'),
|
||||
},
|
||||
'/devtools/widgets-calling': {
|
||||
subtitle: t('tamagotchi.settings.devtools.title'),
|
||||
title: t('tamagotchi.settings.devtools.pages.widgets-calling.title'),
|
||||
},
|
||||
}
|
||||
|
||||
for (const metadata of allProvidersMetadata.value) {
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@proj-airi/stage-ui/components'
|
||||
import { FieldInput, FieldSelect, FieldTextArea } from '@proj-airi/ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { widgetsAdd, widgetsClear, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../shared/eventa'
|
||||
import { useElectronEventaInvoke } from '../../composables/electron-vueuse/use-electron-eventa-context'
|
||||
|
||||
type SizePreset = 's' | 'm' | 'l' | 'custom'
|
||||
|
||||
interface FormState {
|
||||
id: string
|
||||
componentName: string
|
||||
sizePreset: SizePreset
|
||||
customCols: string
|
||||
customRows: string
|
||||
ttlSeconds: string
|
||||
componentProps: string
|
||||
}
|
||||
|
||||
const openWidgets = useElectronEventaInvoke(widgetsOpenWindow)
|
||||
const prepareWindow = useElectronEventaInvoke(widgetsPrepareWindow)
|
||||
const addWidget = useElectronEventaInvoke(widgetsAdd)
|
||||
const updateWidget = useElectronEventaInvoke(widgetsUpdate)
|
||||
const removeWidget = useElectronEventaInvoke(widgetsRemove)
|
||||
const clearWidgets = useElectronEventaInvoke(widgetsClear)
|
||||
|
||||
const defaultWeatherProps = {
|
||||
city: 'Tokyo',
|
||||
temperature: '15°C',
|
||||
condition: 'Sunny',
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
id: '',
|
||||
componentName: 'weather',
|
||||
sizePreset: 'm',
|
||||
customCols: '2',
|
||||
customRows: '1',
|
||||
ttlSeconds: '',
|
||||
componentProps: JSON.stringify(defaultWeatherProps, null, 2),
|
||||
})
|
||||
|
||||
const busy = ref(false)
|
||||
const lastAction = ref('')
|
||||
const lastError = ref('')
|
||||
|
||||
const sizePresetOptions: Array<{ label: string, value: SizePreset }> = [
|
||||
{ label: 'Small (s)', value: 's' },
|
||||
{ label: 'Medium (m)', value: 'm' },
|
||||
{ label: 'Large (l)', value: 'l' },
|
||||
{ label: 'Custom grid', value: 'custom' },
|
||||
]
|
||||
|
||||
const resolvedSize = computed(() => {
|
||||
if (form.sizePreset !== 'custom')
|
||||
return form.sizePreset
|
||||
|
||||
const parsedCols = Number.parseInt(form.customCols, 10)
|
||||
const parsedRows = Number.parseInt(form.customRows, 10)
|
||||
const cols = Number.isFinite(parsedCols) && parsedCols > 0 ? parsedCols : 1
|
||||
const rows = Number.isFinite(parsedRows) && parsedRows > 0 ? parsedRows : 1
|
||||
|
||||
return { cols, rows }
|
||||
})
|
||||
|
||||
function resetFeedback() {
|
||||
lastAction.value = ''
|
||||
lastError.value = ''
|
||||
}
|
||||
|
||||
function parseProps() {
|
||||
try {
|
||||
return JSON.parse(form.componentProps || '{}')
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Invalid JSON in component props: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseTtl() {
|
||||
if (!form.ttlSeconds)
|
||||
return 0
|
||||
|
||||
const ttl = Number(form.ttlSeconds)
|
||||
if (Number.isNaN(ttl) || ttl < 0)
|
||||
throw new Error('TTL must be a positive number of seconds.')
|
||||
|
||||
return Math.floor(ttl * 1000)
|
||||
}
|
||||
|
||||
async function prepareAndOpenWindow(targetId?: string) {
|
||||
try {
|
||||
const id = await prepareWindow(targetId ? { id: targetId } : {})
|
||||
await openWidgets({ id })
|
||||
return id
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to prepare widget window', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!form.componentName.trim()) {
|
||||
lastError.value = 'Component name is required.'
|
||||
return
|
||||
}
|
||||
|
||||
resetFeedback()
|
||||
busy.value = true
|
||||
|
||||
try {
|
||||
const componentProps = parseProps()
|
||||
const ttlMs = parseTtl()
|
||||
const desiredId = form.id || undefined
|
||||
const preparedId = await prepareAndOpenWindow(desiredId)
|
||||
const createdId = await addWidget({ id: preparedId, componentName: form.componentName.trim(), componentProps, size: resolvedSize.value, ttlMs })
|
||||
|
||||
const resolvedId = createdId || preparedId
|
||||
if (!form.id && resolvedId)
|
||||
form.id = resolvedId
|
||||
|
||||
lastAction.value = `Spawned widget${resolvedId ? ` (${resolvedId})` : ''}.`
|
||||
}
|
||||
catch (error) {
|
||||
lastError.value = (error as Error).message || 'Failed to spawn widget.'
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!form.id) {
|
||||
lastError.value = 'Widget id is required to update.'
|
||||
return
|
||||
}
|
||||
|
||||
resetFeedback()
|
||||
busy.value = true
|
||||
|
||||
try {
|
||||
const componentProps = parseProps()
|
||||
await updateWidget({
|
||||
id: form.id,
|
||||
componentProps,
|
||||
})
|
||||
lastAction.value = `Updated widget (${form.id}).`
|
||||
}
|
||||
catch (error) {
|
||||
lastError.value = (error as Error).message || 'Failed to update widget.'
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!form.id) {
|
||||
lastError.value = 'Widget id is required to remove.'
|
||||
return
|
||||
}
|
||||
|
||||
resetFeedback()
|
||||
busy.value = true
|
||||
|
||||
try {
|
||||
await removeWidget({ id: form.id })
|
||||
lastAction.value = `Removed widget (${form.id}).`
|
||||
}
|
||||
catch (error) {
|
||||
lastError.value = (error as Error).message || 'Failed to remove widget.'
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
resetFeedback()
|
||||
busy.value = true
|
||||
|
||||
try {
|
||||
await clearWidgets()
|
||||
lastAction.value = 'Cleared all widgets.'
|
||||
}
|
||||
catch (error) {
|
||||
lastError.value = (error as Error).message || 'Failed to clear widgets.'
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyWeatherPreset() {
|
||||
form.componentName = 'weather'
|
||||
form.sizePreset = 'm'
|
||||
form.customCols = '2'
|
||||
form.customRows = '1'
|
||||
form.componentProps = JSON.stringify(defaultWeatherProps, null, 2)
|
||||
form.ttlSeconds = ''
|
||||
resetFeedback()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-300">
|
||||
Spawn widgets in the overlay window to validate component-calling integrations.
|
||||
</p>
|
||||
<p class="text-xs text-neutral-400 dark:text-neutral-500">
|
||||
Provide an existing id to mutate a widget or leave blank to spawn a new one.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="busy"
|
||||
@click="applyWeatherPreset"
|
||||
>
|
||||
Weather Preset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<FieldInput
|
||||
v-model="form.id"
|
||||
label="Widget Id"
|
||||
description="Optional. Fills automatically after spawning."
|
||||
placeholder="Auto-generated if empty"
|
||||
:required="false"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="form.componentName"
|
||||
label="Component Name"
|
||||
description="Matches a component registered in the widgets overlay."
|
||||
placeholder="e.g. weather"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<FieldSelect
|
||||
v-model="form.sizePreset"
|
||||
label="Size Preset"
|
||||
description="Choose a preset or opt into custom spans."
|
||||
:options="sizePresetOptions"
|
||||
placeholder="Select size"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="form.customCols"
|
||||
label="Custom Columns"
|
||||
description="Used when preset is Custom."
|
||||
type="number"
|
||||
min="1"
|
||||
:disabled="form.sizePreset !== 'custom'"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="form.customRows"
|
||||
label="Custom Rows"
|
||||
description="Used when preset is Custom."
|
||||
type="number"
|
||||
min="1"
|
||||
:disabled="form.sizePreset !== 'custom'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldInput
|
||||
v-model="form.ttlSeconds"
|
||||
label="TTL (seconds)"
|
||||
description="0 keeps the widget alive until closed manually."
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
:required="false"
|
||||
/>
|
||||
|
||||
<FieldTextArea
|
||||
v-model="form.componentProps"
|
||||
label="Component Props (JSON)"
|
||||
description="Provide valid JSON for the widget props."
|
||||
:rows="8"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
:disabled="busy"
|
||||
@click="handleAdd"
|
||||
>
|
||||
Spawn / Replace
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="busy"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
Update Props
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="busy"
|
||||
@click="handleRemove"
|
||||
>
|
||||
Remove Widget
|
||||
</Button>
|
||||
<Button
|
||||
class="ml-auto"
|
||||
variant="danger"
|
||||
:disabled="busy"
|
||||
@click="handleClear"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-sm space-y-1">
|
||||
<p v-if="lastAction" class="text-primary-200/90">
|
||||
{{ lastAction }}
|
||||
</p>
|
||||
<p v-if="lastError" class="text-danger-200/90">
|
||||
{{ lastError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
</route>
|
||||
@@ -29,6 +29,12 @@ const menu = computed(() => [
|
||||
icon: 'i-solar:sledgehammer-bold-duotone',
|
||||
to: '/devtools/use-electron-all-displays',
|
||||
},
|
||||
{
|
||||
title: 'Widgets Calling',
|
||||
description: 'Spawn overlay widgets and test component props',
|
||||
icon: 'i-solar:sledgehammer-bold-duotone',
|
||||
to: '/devtools/widgets-calling',
|
||||
},
|
||||
{
|
||||
title: 'Relative Mouse',
|
||||
description: 'Get mouse position relative to the window',
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import type { WidgetSnapshot } from '../../shared/eventa'
|
||||
|
||||
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { widgetsClearEvent, widgetsFetch, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../shared/eventa'
|
||||
import { useElectronEventaContext, useElectronEventaInvoke } from '../composables/electron-vueuse'
|
||||
|
||||
type SizePreset = 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
|
||||
interface WidgetItem {
|
||||
id: string
|
||||
componentName: string
|
||||
componentProps: Record<string, any>
|
||||
size: SizePreset
|
||||
ttlMs: number
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const widgetId = computed(() => {
|
||||
const raw = route.query.id
|
||||
if (typeof raw === 'string')
|
||||
return raw
|
||||
if (Array.isArray(raw))
|
||||
return raw[0]
|
||||
return undefined
|
||||
})
|
||||
|
||||
const widget = ref<WidgetItem | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const context = useElectronEventaContext()
|
||||
const removeWidgetInvoke = useElectronEventaInvoke(widgetsRemove)
|
||||
const fetchWidget = useElectronEventaInvoke(widgetsFetch)
|
||||
|
||||
let ttlTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function clearTtl() {
|
||||
if (ttlTimer) {
|
||||
clearTimeout(ttlTimer)
|
||||
ttlTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRemoval(id: string) {
|
||||
clearTtl()
|
||||
try {
|
||||
await removeWidgetInvoke({ id })
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to remove widget', error)
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: WidgetSnapshot) {
|
||||
clearTtl()
|
||||
widget.value = {
|
||||
id: snapshot.id,
|
||||
componentName: snapshot.componentName,
|
||||
componentProps: snapshot.componentProps ?? {},
|
||||
size: snapshot.size ?? 'm',
|
||||
ttlMs: snapshot.ttlMs ?? 0,
|
||||
}
|
||||
|
||||
if (snapshot.ttlMs && snapshot.ttlMs > 0) {
|
||||
ttlTimer = setTimeout(() => requestRemoval(snapshot.id), snapshot.ttlMs)
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSnapshot(id: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const snapshot = await fetchWidget({ id })
|
||||
if (widgetId.value !== id)
|
||||
return
|
||||
if (snapshot)
|
||||
applySnapshot(snapshot)
|
||||
else
|
||||
widget.value = null
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to fetch widget snapshot', error)
|
||||
}
|
||||
finally {
|
||||
if (widgetId.value === id)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(widgetId, (id) => {
|
||||
clearTtl()
|
||||
widget.value = null
|
||||
loading.value = false
|
||||
if (!id)
|
||||
return
|
||||
requestSnapshot(id)
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
context.value.on(widgetsRenderEvent, (evt) => {
|
||||
const body = evt?.body
|
||||
if (!body || body.id !== widgetId.value)
|
||||
return
|
||||
applySnapshot(body)
|
||||
})
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
context.value.on(widgetsUpdateEvent, (evt) => {
|
||||
const body = evt?.body
|
||||
if (!body || body.id !== widgetId.value)
|
||||
return
|
||||
|
||||
if (!widget.value) {
|
||||
requestSnapshot(body.id)
|
||||
return
|
||||
}
|
||||
|
||||
widget.value = {
|
||||
...widget.value,
|
||||
componentProps: body.componentProps ?? widget.value.componentProps,
|
||||
}
|
||||
})
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
context.value.on(widgetsRemoveEvent, (evt) => {
|
||||
const body = evt?.body
|
||||
if (!body || body.id !== widgetId.value)
|
||||
return
|
||||
clearTtl()
|
||||
widget.value = null
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
context.value.on(widgetsClearEvent, () => {
|
||||
clearTtl()
|
||||
widget.value = null
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
catch {}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTtl()
|
||||
})
|
||||
|
||||
const Registry: Record<string, ReturnType<typeof defineAsyncComponent>> = {
|
||||
weather: defineAsyncComponent(async () => (await import('../widgets/weather')).Weather),
|
||||
}
|
||||
|
||||
const GenericWidget = defineComponent({
|
||||
name: 'GenericWidget',
|
||||
props: { title: { type: String, required: true }, modelValue: { type: Object, default: () => ({}) } },
|
||||
setup(props) {
|
||||
return () => h('div', { class: 'h-full w-full flex flex-col gap-2 rounded-xl border border-neutral-200/30 bg-[rgba(28,28,28,0.72)] p-3 text-neutral-100 shadow-[0_8px_20px_rgba(0,0,0,0.35)] backdrop-blur-md dark:border-neutral-700/30' }, [
|
||||
h('div', { class: 'flex items-center justify-between' }, [
|
||||
h('div', { class: 'text-sm font-medium opacity-90' }, props.title),
|
||||
]),
|
||||
h('div', { class: 'pointer-events-auto max-h-full min-h-0 flex-1 overflow-auto rounded-md bg-black/10 p-2 text-[11px]' }, [
|
||||
h('pre', { class: 'whitespace-pre-wrap break-words opacity-80' }, JSON.stringify(props.modelValue, null, 2)),
|
||||
]),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
function resolveWidgetComponent(name: string) {
|
||||
const key = name?.trim()
|
||||
if (!key)
|
||||
return GenericWidget
|
||||
|
||||
if (Registry[key])
|
||||
return Registry[key]
|
||||
|
||||
const normalized = key.toLowerCase()
|
||||
if (Registry[normalized])
|
||||
return Registry[normalized]
|
||||
|
||||
return GenericWidget
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
clearTtl()
|
||||
window.close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-3">
|
||||
<div v-if="!widgetId" class="h-full flex items-center justify-center">
|
||||
<div class="border border-neutral-200/20 rounded-xl bg-neutral-900/40 px-4 py-3 text-sm text-neutral-200/80 backdrop-blur">
|
||||
Missing widget id. Launch the window via a component call to populate this view.
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="widget" class="relative h-full">
|
||||
<button
|
||||
class="absolute right-2 top-2 z-10 size-7 rounded-full bg-black/40 text-xs text-white transition hover:bg-black/60"
|
||||
title="Close widget"
|
||||
@click="handleClose"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<component
|
||||
:is="resolveWidgetComponent(widget.componentName)"
|
||||
:key="widget.id"
|
||||
:title="widget.componentName"
|
||||
:model-value="widget.componentProps"
|
||||
v-bind="widget.componentProps"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<div class="border border-neutral-200/20 rounded-xl bg-neutral-900/40 px-4 py-3 text-sm text-neutral-200/80 backdrop-blur">
|
||||
{{ loading ? 'Loading widget...' : `Waiting for widget data for "${widgetId}"` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="[-webkit-app-region:drag] pointer-events-none absolute left-1/2 top-1 h-[14px] w-[36px] rounded-[10px] bg-[rgba(125,125,125,0.28)] backdrop-blur-[6px] -translate-x-1/2">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: stage
|
||||
</route>
|
||||
@@ -0,0 +1,10 @@
|
||||
## Acknowledgements
|
||||
|
||||
- [Meteocons | Bas Milius — Full-Stack Developer](https://bas.dev/work/meteocons)
|
||||
|
||||
## Many other alternatives
|
||||
|
||||
- [Weather Icons by Bas](https://basmilius.github.io/weather-icons/index-fill.html)
|
||||
- [erikflowers/weather-icons: 215 Weather Themed Icons and CSS](https://github.com/erikflowers/weather-icons)
|
||||
- [basmilius/weather-icons: Free to use animated weather icons.](https://github.com/basmilius/weather-icons)
|
||||
- [Makin-Things/weather-icons: A set of updated weather icons based of the AmCharts style of icon.](https://github.com/Makin-Things/weather-icons)
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="a" x1="26.75" y1="22.91" x2="37.25" y2="41.09" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fbbf24"/>
|
||||
<stop offset="0.45" stop-color="#fbbf24"/>
|
||||
<stop offset="1" stop-color="#f59e0b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="10.5" stroke="#f8af18" stroke-miterlimit="10" stroke-width="0.5" fill="url(#a)"/>
|
||||
<path d="M32,15.71V9.5m0,45V48.29M43.52,20.48l4.39-4.39M16.09,47.91l4.39-4.39m0-23-4.39-4.39M47.91,47.91l-4.39-4.39M15.71,32H9.5m45,0H48.29" fill="none" stroke="#fbbf24" stroke-linecap="round" stroke-miterlimit="10" stroke-width="3">
|
||||
<animateTransform attributeName="transform" dur="45s" values="0 32 32; 360 32 32" repeatCount="indefinite" type="rotate"/>
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 817 B |
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
animation?: 'pulse' | 'wave' | 'none'
|
||||
}>(), {
|
||||
animation: 'pulse',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="skeleton"
|
||||
:class="props.animation !== 'none' ? `skeleton-${props.animation}` : ''"
|
||||
bg="neutral-200 dark:neutral-800"
|
||||
overflow="hidden"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skeleton {
|
||||
position: relative;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Pulse animation */
|
||||
.skeleton-pulse {
|
||||
animation: skeleton-pulse 2s ease-in-out 0.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wave animation */
|
||||
.skeleton-wave::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(90deg, transparent, rgb(255, 255, 255), transparent);
|
||||
animation: skeleton-wave 2s ease-in-out infinite;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.dark .skeleton-wave::after {
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
}
|
||||
|
||||
@keyframes skeleton-wave {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: translateX(100%);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import ClearDay from '../assets/clear-day.svg'
|
||||
import Skeleton from './Skeleton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
propsLoading: boolean
|
||||
|
||||
city?: string
|
||||
temperature?: string
|
||||
condition?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div h-full w-full>
|
||||
<Skeleton v-if="props.propsLoading" rounded-2xl py-2 pl-3 pr-1 class="grid grid-cols-4 grid-rows-3 max-h-35 gap-2">
|
||||
<Skeleton animation="wave" class="grid-col-span-3 h-[1lh] w-20% rounded-2xl" />
|
||||
<div class="col-span-1 row-span-2 h-20 w-20 justify-self-end" />
|
||||
<Skeleton animation="wave" class="col-span-2 row-span-2 h-full w-20% inline-flex items-end rounded-2xl text-gray-600 font-thin dark:text-gray-300" />
|
||||
<Skeleton animation="wave" class="col-span-2 row-span-1 h-full w-20% inline-flex items-end justify-self-end rounded-2xl pr-4 text-gray-500 dark:text-gray-400" />
|
||||
</Skeleton>
|
||||
<div v-else bg="blue-100 dark:blue-900" rounded-2xl py-2 pl-3 pr-1 class="grid grid-cols-4 grid-rows-3 h-full gap-2">
|
||||
<div class="grid-col-span-2 text-lg font-semibold">
|
||||
{{ props.city }}
|
||||
</div>
|
||||
<img :src="ClearDay" alt="Weather Icon" class="col-span-2 row-span-2 h-full w-auto justify-self-end">
|
||||
<div class="col-span-2 row-span-2 h-full inline-flex items-end text-gray-600 font-thin dark:text-gray-300">
|
||||
<span class="text-[3.5rem] font-thin leading-[1]">{{ props.temperature }}</span>
|
||||
</div>
|
||||
<div class="col-span-2 row-span-2 h-full w-full inline-flex items-end justify-end pr-4 text-gray-500 dark:text-gray-400">
|
||||
<span>{{ props.condition }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Weather } from './components/Weather.vue'
|
||||
@@ -9,4 +9,37 @@ export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:el
|
||||
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
|
||||
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
|
||||
|
||||
// Widgets / Adhoc window events
|
||||
export interface WidgetsAddPayload {
|
||||
id?: string
|
||||
componentName: string
|
||||
componentProps?: Record<string, any>
|
||||
// size presets or explicit spans; renderer decides mapping
|
||||
size?: 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
// auto-dismiss in ms; if omitted, persistent until closed by user
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export interface WidgetSnapshot {
|
||||
id: string
|
||||
componentName: string
|
||||
componentProps: Record<string, any>
|
||||
size: 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
ttlMs: number
|
||||
}
|
||||
|
||||
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
|
||||
export const widgetsAdd = defineInvokeEventa<string | undefined, WidgetsAddPayload>('eventa:invoke:electron:windows:widgets:add')
|
||||
export const widgetsRemove = defineInvokeEventa<void, { id: string }>('eventa:invoke:electron:windows:widgets:remove')
|
||||
export const widgetsClear = defineInvokeEventa('eventa:invoke:electron:windows:widgets:clear')
|
||||
export const widgetsUpdate = defineInvokeEventa<void, { id: string, componentProps?: Record<string, any> }>('eventa:invoke:electron:windows:widgets:update')
|
||||
export const widgetsFetch = defineInvokeEventa<WidgetSnapshot | void, { id: string }>('eventa:invoke:electron:windows:widgets:fetch')
|
||||
export const widgetsPrepareWindow = defineInvokeEventa<string | undefined, { id?: string }>('eventa:invoke:electron:windows:widgets:prepare')
|
||||
|
||||
// Internal event from main -> widgets renderer when a widget should render
|
||||
export const widgetsRenderEvent = defineEventa<WidgetSnapshot>('eventa:event:electron:windows:widgets:render')
|
||||
export const widgetsRemoveEvent = defineEventa<{ id: string }>('eventa:event:electron:windows:widgets:remove')
|
||||
export const widgetsClearEvent = defineEventa('eventa:event:electron:windows:widgets:clear')
|
||||
export const widgetsUpdateEvent = defineEventa<{ id: string, componentProps?: Record<string, any> }>('eventa:event:electron:windows:widgets:update')
|
||||
|
||||
export { electron } from './electron'
|
||||
|
||||
@@ -581,6 +581,9 @@ pages:
|
||||
cerebras:
|
||||
description: cerebras.ai
|
||||
title: Cerebras
|
||||
aliyun-nls:
|
||||
description: Aliyun NLS
|
||||
title: Aliyun NLS
|
||||
transcriptions:
|
||||
playground:
|
||||
title: Transcription Playground
|
||||
|
||||
@@ -27,3 +27,8 @@ pages:
|
||||
use-window-mouse:
|
||||
title: useWindowMouse
|
||||
description: Test the Electron window cursor position
|
||||
devtools:
|
||||
title: Developer
|
||||
pages:
|
||||
widgets-calling:
|
||||
title: Widget Calling
|
||||
|
||||
Reference in New Issue
Block a user