feat(stage-tamagotchi): widget with pin (#1993)

This commit is contained in:
Neko
2026-06-19 02:03:31 +08:00
committed by GitHub
parent 7e3698147d
commit 8930211d1b
9 changed files with 175 additions and 22 deletions
@@ -279,6 +279,7 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {
id: payload.id ?? Math.random().toString(36).slice(2, 10),
componentName: payload.componentName,
componentProps: payload.componentProps ?? {},
alwaysOnTop: payload.alwaysOnTop ?? false,
size: payload.size ?? 'm',
windowSize: payload.windowSize,
ttlMs: payload.ttlMs ?? 0,
@@ -296,6 +297,7 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {
widgetSnapshots.set(payload.id, {
...existing,
componentProps: payload.componentProps ?? existing.componentProps,
alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop,
size: payload.size ?? existing.size,
windowSize: payload.windowSize ?? existing.windowSize,
ttlMs: payload.ttlMs ?? existing.ttlMs,
@@ -14,6 +14,7 @@ describe('widget invoke validation', () => {
id: ' widget-1 ',
componentName: ' weather ',
componentProps: { city: 'Tokyo' },
alwaysOnTop: true,
ttlMs: 2500.9,
windowSize: {
width: 620.8,
@@ -24,6 +25,7 @@ describe('widget invoke validation', () => {
id: 'widget-1',
componentName: 'weather',
componentProps: { city: 'Tokyo' },
alwaysOnTop: true,
ttlMs: 2500,
windowSize: {
width: 620,
@@ -52,6 +54,11 @@ describe('widget invoke validation', () => {
componentName: 'weather',
windowSize: { width: 0, height: 320 },
} as any)).toThrow('windowSize must contain a positive finite width and height.')
expect(() => validateWidgetsAddPayload({
componentName: 'weather',
alwaysOnTop: 'yes' as any,
})).toThrow('alwaysOnTop must be a boolean when provided.')
})
})
@@ -60,10 +67,12 @@ describe('widget invoke validation', () => {
expect(validateWidgetsUpdatePayload({
id: ' widget-1 ',
componentProps: { city: 'Taipei' },
alwaysOnTop: false,
ttlMs: 1500.4,
})).toEqual({
id: 'widget-1',
componentProps: { city: 'Taipei' },
alwaysOnTop: false,
ttlMs: 1500,
windowSize: undefined,
})
@@ -83,6 +92,11 @@ describe('widget invoke validation', () => {
id: 'widget-1',
windowSize: { width: Number.NaN, height: 400 },
} as any)).toThrow('windowSize must contain a positive finite width and height.')
expect(() => validateWidgetsUpdatePayload({
id: 'widget-1',
alwaysOnTop: 'yes' as any,
})).toThrow('alwaysOnTop must be a boolean when provided.')
})
})
@@ -35,6 +35,16 @@ function normalizeComponentProps(componentProps?: Record<string, unknown>): Reco
return componentProps
}
function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined {
if (value === undefined)
return undefined
if (typeof value !== 'boolean')
throw new Error(`${fieldName} must be a boolean when provided.`)
return value
}
/**
* Validates and normalizes widget spawn payloads at the Electron invoke boundary.
*
@@ -44,6 +54,7 @@ function normalizeComponentProps(componentProps?: Record<string, unknown>): Reco
* Expects:
* - `componentName` is a non-empty string
* - `componentProps`, when provided, is a plain object
* - `alwaysOnTop`, when provided, is a boolean
* - `ttlMs`, when provided, is a non-negative finite number
*
* Returns:
@@ -69,6 +80,7 @@ export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsA
id: normalizeWidgetId(payload.id),
componentName,
componentProps: normalizeComponentProps(payload.componentProps),
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
ttlMs: normalizeTtlMs(payload.ttlMs),
windowSize: normalizedWindowSize,
}
@@ -83,6 +95,7 @@ export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsA
* Expects:
* - `id` is a non-empty string after trimming
* - `componentProps`, when provided, is a plain object
* - `alwaysOnTop`, when provided, is a boolean
*
* Returns:
* - A normalized payload safe to pass into the widgets manager
@@ -108,6 +121,7 @@ export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): Wi
componentProps: payload.componentProps === undefined
? undefined
: normalizeComponentProps(payload.componentProps),
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
ttlMs: payload.ttlMs === undefined
? undefined
: normalizeTtlMs(payload.ttlMs),
@@ -204,8 +204,6 @@ function createWidgetsWindow() {
...spotlightLikeWindowConfig(),
})
// Keep on top like caption/main overlays
window.setAlwaysOnTop(true, 'screen-saver', 1)
window.setFullScreenable(false)
window.setVisibleOnAllWorkspaces(true)
if (isMacOS)
@@ -220,6 +218,15 @@ function createWidgetsWindow() {
return window
}
function applyAlwaysOnTop(window: BrowserWindow, enabled: boolean) {
if (enabled) {
window.setAlwaysOnTop(true, 'screen-saver', 1)
return
}
window.setAlwaysOnTop(false)
}
interface WidgetRecord extends WidgetSnapshot {
timer?: ReturnType<typeof setTimeout>
}
@@ -462,11 +469,12 @@ export function setupWidgetsWindowManager(params: {
return resolved
}
async function showWindowWithRoute(route: string, context?: WidgetWindowContext, snapshot?: Pick<WidgetSnapshot, 'windowSize'>) {
async function showWindowWithRoute(route: string, context?: WidgetWindowContext, snapshot?: Pick<WidgetSnapshot, 'alwaysOnTop' | 'windowSize'>) {
pendingRoute = route
const window = await getWindowFromContext(context)
pendingRoute = undefined
applyWindowLayout(window, snapshot)
applyAlwaysOnTop(window, snapshot?.alwaysOnTop ?? false)
if (currentRoute !== route)
await loadWithRoute(window, route)
window.show()
@@ -529,6 +537,7 @@ export function setupWidgetsWindowManager(params: {
id,
componentName: payload.componentName,
componentProps: payload.componentProps ?? {},
alwaysOnTop: payload.alwaysOnTop ?? false,
size: payload.size ?? 'm',
windowSize: resolveWindowSizeFromPayload(payload),
ttlMs: payload.ttlMs ?? 0,
@@ -564,6 +573,7 @@ export function setupWidgetsWindowManager(params: {
const nextSnapshot: WidgetSnapshot = {
...toSnapshot(existing),
componentProps: payload.componentProps ?? existing.componentProps,
alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop,
size: payload.size ?? existing.size,
windowSize: normalizeWidgetWindowSize(payload.windowSize) ?? existing.windowSize,
ttlMs: payload.ttlMs ?? existing.ttlMs,
@@ -573,12 +583,15 @@ export function setupWidgetsWindowManager(params: {
const context = windowContexts.get(payload.id)
const window = context?.window
if (window && !window.isDestroyed())
if (window && !window.isDestroyed()) {
applyWindowLayout(window, nextSnapshot)
applyAlwaysOnTop(window, nextSnapshot.alwaysOnTop)
}
eventaContext?.emit(widgetsUpdateEvent, {
id: nextSnapshot.id,
componentProps: nextSnapshot.componentProps,
alwaysOnTop: nextSnapshot.alwaysOnTop,
size: nextSnapshot.size,
windowSize: nextSnapshot.windowSize,
ttlMs: nextSnapshot.ttlMs,
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { Button, FieldCombobox, FieldInput, FieldTextArea } from '@proj-airi/ui'
import { Button, FieldCheckbox, FieldCombobox, FieldInput, FieldTextArea } from '@proj-airi/ui'
import { computed, reactive, ref } from 'vue'
import { widgetsAdd, widgetsClear, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../shared/eventa'
@@ -13,6 +13,7 @@ interface FormState {
sizePreset: SizePreset
customCols: string
customRows: string
alwaysOnTop: boolean
ttlSeconds: string
componentProps: string
}
@@ -68,6 +69,7 @@ const form = reactive<FormState>({
sizePreset: 'm',
customCols: '2',
customRows: '1',
alwaysOnTop: false,
ttlSeconds: '',
componentProps: JSON.stringify(defaultWeatherProps, null, 2),
})
@@ -146,7 +148,14 @@ async function handleAdd() {
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 createdId = await addWidget({
id: preparedId,
componentName: form.componentName.trim(),
componentProps,
alwaysOnTop: form.alwaysOnTop,
size: resolvedSize.value,
ttlMs,
})
const resolvedId = createdId || preparedId
if (!form.id && resolvedId)
@@ -176,6 +185,7 @@ async function handleUpdate() {
await updateWidget({
id: form.id,
componentProps,
alwaysOnTop: form.alwaysOnTop,
})
lastAction.value = `Updated widget (${form.id}).`
}
@@ -230,6 +240,7 @@ function applyWeatherPreset() {
form.customCols = '2'
form.customRows = '1'
form.componentProps = JSON.stringify(defaultWeatherProps, null, 2)
form.alwaysOnTop = false
form.ttlSeconds = ''
resetFeedback()
}
@@ -240,6 +251,7 @@ function applyMapPreset() {
form.customCols = '3'
form.customRows = '2'
form.componentProps = JSON.stringify(defaultMapProps, null, 2)
form.alwaysOnTop = false
form.ttlSeconds = ''
resetFeedback()
}
@@ -250,6 +262,7 @@ function applyExtensionUiPreset() {
form.customCols = '4'
form.customRows = '3'
form.componentProps = JSON.stringify({}, null, 2)
form.alwaysOnTop = false
form.ttlSeconds = ''
resetFeedback()
}
@@ -375,6 +388,12 @@ function applyExtensionUiPreset() {
:required="false"
/>
<FieldCheckbox
v-model="form.alwaysOnTop"
label="Pin on top"
description="Keep this widget window above other windows after spawning or updating."
/>
<FieldTextArea
v-model="form.componentProps"
label="Component Props (JSON)"
@@ -2,11 +2,11 @@
import type { WidgetSnapshot, WidgetWindowSize } from '../../shared/eventa'
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { widgetsClearEvent, widgetsFetch, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../shared/eventa'
import { widgetsClearEvent, widgetsFetch, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdate, widgetsUpdateEvent } from '../../shared/eventa'
const { t } = useI18n()
@@ -16,6 +16,7 @@ interface WidgetItem {
id: string
componentName: string
componentProps: Record<string, any>
alwaysOnTop: boolean
size: SizePreset
windowSize?: WidgetWindowSize
ttlMs: number
@@ -38,6 +39,8 @@ const loading = ref(false)
const context = useElectronEventaContext()
const removeWidgetInvoke = useElectronEventaInvoke(widgetsRemove)
const fetchWidget = useElectronEventaInvoke(widgetsFetch)
const updateWidgetInvoke = useElectronEventaInvoke(widgetsUpdate)
const pinUpdating = shallowRef(false)
let ttlTimer: ReturnType<typeof setTimeout> | undefined
@@ -64,6 +67,7 @@ function applySnapshot(snapshot: WidgetSnapshot) {
id: snapshot.id,
componentName: snapshot.componentName,
componentProps: snapshot.componentProps ?? {},
alwaysOnTop: snapshot.alwaysOnTop ?? false,
size: snapshot.size ?? 'm',
windowSize: snapshot.windowSize,
ttlMs: snapshot.ttlMs ?? 0,
@@ -128,6 +132,7 @@ onMounted(() => {
applySnapshot({
...widget.value,
componentProps: body.componentProps ?? widget.value.componentProps,
alwaysOnTop: body.alwaysOnTop ?? widget.value.alwaysOnTop,
size: body.size ?? widget.value.size,
windowSize: body.windowSize ?? widget.value.windowSize,
ttlMs: body.ttlMs ?? widget.value.ttlMs,
@@ -203,21 +208,71 @@ function handleClose() {
clearTtl()
window.close()
}
async function toggleAlwaysOnTop() {
if (!widget.value || pinUpdating.value)
return
const previous = widget.value.alwaysOnTop
const next = !previous
widget.value = {
...widget.value,
alwaysOnTop: next,
}
pinUpdating.value = true
try {
await updateWidgetInvoke({ id: widget.value.id, alwaysOnTop: next })
}
catch (error) {
widget.value = widget.value
? {
...widget.value,
alwaysOnTop: previous,
}
: widget.value
console.warn('Failed to update widget pin state', error)
}
finally {
pinUpdating.value = false
}
}
</script>
<template>
<div :class="['relative h-full w-full']">
<button
:class="[
'absolute right-2 top-2 z-10 size-7 rounded-full text-xs text-white transition',
'bg-black/40 hover:bg-black/60',
]"
:title="t('tamagotchi.stage.widgets.close')"
:aria-label="t('tamagotchi.stage.widgets.close')"
@click="handleClose"
>
</button>
<div :class="['absolute right-2 top-2 z-10 flex items-center gap-1']">
<button
v-if="widget"
:class="[
'size-7 flex items-center justify-center rounded-full text-white transition',
widget.alwaysOnTop ? 'bg-primary-500/70 hover:bg-primary-500/85' : 'bg-black/40 hover:bg-black/60',
pinUpdating ? 'cursor-wait opacity-70' : '',
]"
:title="widget.alwaysOnTop ? t('tamagotchi.stage.controls-island.unpin-from-top') : t('tamagotchi.stage.controls-island.pin-on-top')"
:aria-label="widget.alwaysOnTop ? t('tamagotchi.stage.controls-island.unpin-from-top') : t('tamagotchi.stage.controls-island.pin-on-top')"
:disabled="pinUpdating"
@click="toggleAlwaysOnTop"
>
<div
:class="[
'size-4',
widget.alwaysOnTop ? 'i-solar:pin-bold' : 'i-solar:pin-linear opacity-80',
]"
/>
</button>
<button
:class="[
'size-7 rounded-full text-xs text-white transition',
'bg-black/40 hover:bg-black/60',
]"
:title="t('tamagotchi.stage.widgets.close')"
:aria-label="t('tamagotchi.stage.widgets.close')"
@click="handleClose"
>
x
</button>
</div>
<div v-if="!widgetId" :class="['h-full flex items-center justify-center p-6']">
<div
:class="[
@@ -363,6 +363,30 @@ describe('widgets tool helpers', () => {
})
})
it('forwards always-on-top preference when spawning a widget', async () => {
const invokers = makeInvokers()
vi.mocked(invokers.addWidget).mockResolvedValue('pinned-widget')
await executeWidgetAction({
action: 'spawn',
id: ' pinned-widget ',
componentName: 'weather',
componentProps: '{"city":"Tokyo"}',
size: 'm',
ttlSeconds: 0,
alwaysOnTop: true,
}, { invokers })
expect(invokers.addWidget).toHaveBeenCalledWith({
id: 'pinned-widget',
componentName: 'weather',
componentProps: { city: 'Tokyo' },
size: 'm',
alwaysOnTop: true,
ttlMs: 0,
})
})
it('forwards custom window sizing when spawning a widget', async () => {
const invokers = makeInvokers()
vi.mocked(invokers.addWidget).mockResolvedValue('sized-widget')
@@ -492,10 +516,11 @@ describe('widgets tool helpers', () => {
componentName: '',
componentProps: '{"foo":1}',
size: 'm',
alwaysOnTop: false,
ttlSeconds: 0,
}, { invokers })
expect(invokers.updateWidget).toHaveBeenCalledWith({ id: 'xyz', componentProps: { foo: 1 } })
expect(invokers.updateWidget).toHaveBeenCalledWith({ id: 'xyz', componentProps: { foo: 1 }, alwaysOnTop: false })
})
it('removes when id provided', async () => {
@@ -21,6 +21,7 @@ type WidgetActionInput
id: string
componentName: string
componentProps: string | Record<string, any>
alwaysOnTop?: boolean
size: SizePreset
windowSize?: WidgetWindowSize
ttlSeconds: number
@@ -30,6 +31,7 @@ type WidgetActionInput
id: string
componentProps: string | Record<string, any>
componentName?: string
alwaysOnTop?: boolean
size?: SizePreset
windowSize?: WidgetWindowSize
ttlSeconds?: number
@@ -39,6 +41,7 @@ type WidgetActionInput
id: string
componentName?: string
componentProps?: string | Record<string, any>
alwaysOnTop?: boolean
size?: SizePreset
windowSize?: WidgetWindowSize
ttlSeconds?: number
@@ -48,6 +51,7 @@ type WidgetActionInput
id: string
componentName?: string
componentProps?: string | Record<string, any>
alwaysOnTop?: boolean
size?: SizePreset
windowSize?: WidgetWindowSize
ttlSeconds?: number
@@ -57,6 +61,7 @@ type WidgetActionInput
id: string
componentName?: string
componentProps?: string | Record<string, any>
alwaysOnTop?: boolean
size?: SizePreset
windowSize?: WidgetWindowSize
ttlSeconds?: number
@@ -106,6 +111,7 @@ const widgetParams = z.object({
id: z.string().describe('Widget id; required for update/remove, optional for spawn/open'),
componentName: z.string().describe('Widget component to render, e.g. weather (required for spawn)'),
componentProps: z.string().describe('Widget props as JSON string (e.g. {"city":"Tokyo"})'),
alwaysOnTop: z.boolean().describe('Whether the widget window should stay above other windows. Defaults to false when omitted by internal callers.'),
size: z.enum(['s', 'm', 'l']),
windowSize: z.union([widgetWindowSizeParams, z.null()]).describe('Optional pixel window size and constraints, e.g. {"width":620,"height":760,"minWidth":480}'),
ttlSeconds: z.number().int().nonnegative().describe('Auto-close timer in seconds (spawn only)'),
@@ -257,7 +263,8 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv
componentName: input.componentName,
componentProps: sanitizedComponentProps,
size: input.size ?? 'm',
windowSize,
...(input.alwaysOnTop === undefined ? {} : { alwaysOnTop: input.alwaysOnTop }),
...(windowSize === undefined ? {} : { windowSize }),
ttlMs,
})
@@ -273,7 +280,8 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv
await invokers.updateWidget({
id: normalizedId,
componentProps: sanitizedComponentProps,
windowSize,
...(input.alwaysOnTop === undefined ? {} : { alwaysOnTop: input.alwaysOnTop }),
...(windowSize === undefined ? {} : { windowSize }),
})
return `Updated widget (${normalizedId}).`
@@ -117,6 +117,7 @@ export interface WidgetsAddPayload {
id?: string
componentName: string
componentProps?: Record<string, any>
alwaysOnTop?: boolean
// size presets or explicit spans; renderer decides mapping
size?: WidgetGridSize
windowSize?: WidgetWindowSize | Record<string, unknown>
@@ -127,6 +128,7 @@ export interface WidgetsAddPayload {
export interface WidgetsUpdatePayload {
id: string
componentProps?: Record<string, any>
alwaysOnTop?: boolean
size?: WidgetGridSize
windowSize?: WidgetWindowSize | Record<string, unknown>
ttlMs?: number
@@ -136,6 +138,7 @@ export interface WidgetSnapshot {
id: string
componentName: string
componentProps: Record<string, any>
alwaysOnTop: boolean
size: WidgetGridSize
windowSize?: WidgetWindowSize
ttlMs: number