import React from "react"; import ReactDOM from "react-dom/client"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { PhysicalPosition, PhysicalSize, currentMonitor, getCurrentWindow, monitorFromPoint } from "@tauri-apps/api/window"; import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet"; import "./styles.css"; type UiSettingsSnapshot = { active_sprite_pack: string; scale: number; visible: boolean; always_on_top: boolean; tauri_animation_slowdown_factor: number; }; type UiSpritePackOption = { id: string; pack_id_or_path: string; }; type UiApiToken = { id: string; label: string; token: string; created_at_ms: number; }; const WINDOW_PADDING = 16; const WINDOW_WORKAREA_MARGIN = 80; const MIN_WINDOW_SIZE = 64; const SIZE_EPSILON = 0.5; const SCALE_EPSILON = 0.0001; const SCALE_MIN = 0.5; const SCALE_MAX = 3.0; const LOGICAL_BASE_FRAME_WIDTH = 512; const LOGICAL_BASE_FRAME_HEIGHT = 512; const SLOWDOWN_FACTOR_MIN = 1; const SLOWDOWN_FACTOR_MAX = 20; const SLOWDOWN_FACTOR_DEFAULT = 3; async function invokeSetSpritePack(packIdOrPath: string): Promise { return invoke("set_sprite_pack", { packIdOrPath }); } async function invokeSetScale(scale: number): Promise { return invoke("set_scale", { scale }); } async function invokeSetVisibility(visible: boolean): Promise { return invoke("set_visibility", { visible }); } async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise { return invoke("set_always_on_top", { alwaysOnTop }); } async function invokeAnimationSlowdownFactor(): Promise { return invoke("tauri_animation_slowdown_factor"); } async function invokeSetAnimationSlowdownFactor(factor: number): Promise { return invoke("set_tauri_animation_slowdown_factor", { factor }); } async function invokeListApiTokens(): Promise { return invoke("list_api_tokens"); } async function invokeCreateApiToken(label?: string): Promise { return invoke("create_api_token", { label }); } async function invokeRenameApiToken(id: string, label: string): Promise { return invoke("rename_api_token", { id, label }); } async function invokeRevokeApiToken(id: string): Promise { return invoke("revoke_api_token", { id }); } function fittedWindowSize( scale: number ): { width: number; height: number } { const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; const width = Math.round( Math.max(LOGICAL_BASE_FRAME_WIDTH * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE) ); const height = Math.round( Math.max(LOGICAL_BASE_FRAME_HEIGHT * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE) ); return { width, height }; } function effectiveScaleForWindowSize(width: number, height: number): number { const availableWidth = Math.max(width - WINDOW_PADDING, MIN_WINDOW_SIZE); const availableHeight = Math.max(height - WINDOW_PADDING, MIN_WINDOW_SIZE); const scaleByWidth = availableWidth / LOGICAL_BASE_FRAME_WIDTH; const scaleByHeight = availableHeight / LOGICAL_BASE_FRAME_HEIGHT; const scale = Math.min(scaleByWidth, scaleByHeight); return Math.max(SCALE_MIN, Math.min(scale, SCALE_MAX)); } async function fitWindowForScale(scale: number): Promise { const window = getCurrentWindow(); const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]); const target = fittedWindowSize(scale); const centerX = outerPosition.x + innerSize.width / 2; const centerY = outerPosition.y + innerSize.height / 2; let targetWidth = target.width; let targetHeight = target.height; let targetX = centerX - targetWidth / 2; let targetY = centerY - targetHeight / 2; let monitor: | { position: { x: number; y: number }; size: { width: number; height: number }; workArea: { position: { x: number; y: number }; size: { width: number; height: number } }; } | null = null; try { monitor = (await monitorFromPoint(centerX, centerY)) ?? (await currentMonitor()); } catch { monitor = null; } if (monitor !== null) { const widthCap = Math.max( monitor.workArea.size.width - WINDOW_WORKAREA_MARGIN, MIN_WINDOW_SIZE ); const heightCap = Math.max( monitor.workArea.size.height - WINDOW_WORKAREA_MARGIN, MIN_WINDOW_SIZE ); targetWidth = Math.min(targetWidth, widthCap); targetHeight = Math.min(targetHeight, heightCap); targetX = centerX - targetWidth / 2; targetY = centerY - targetHeight / 2; } const widthChanged = Math.abs(targetWidth - innerSize.width) > SIZE_EPSILON; const heightChanged = Math.abs(targetHeight - innerSize.height) > SIZE_EPSILON; if (widthChanged || heightChanged) { await window.setSize(new PhysicalSize(targetWidth, targetHeight)); if (monitor !== null) { const minX = Math.round(monitor.workArea.position.x); const minY = Math.round(monitor.workArea.position.y); const maxX = Math.round( monitor.workArea.position.x + monitor.workArea.size.width - targetWidth ); const maxY = Math.round( monitor.workArea.position.y + monitor.workArea.size.height - targetHeight ); targetX = maxX < minX ? minX : Math.min(Math.max(targetX, minX), maxX); targetY = maxY < minY ? minY : Math.min(Math.max(targetY, minY), maxY); } await window.setPosition(new PhysicalPosition(Math.round(targetX), Math.round(targetY))); } return effectiveScaleForWindowSize(targetWidth, targetHeight); } function MainOverlayWindow(): JSX.Element { const [snapshot, setSnapshot] = React.useState(null); const [error, setError] = React.useState(null); const [debugOverlayVisible, setDebugOverlayVisible] = React.useState(false); const hostRef = React.useRef(null); const rendererRef = React.useRef(null); const activePackRef = React.useRef(null); const loadedPackKeyRef = React.useRef(null); const effectiveScaleSyncRef = React.useRef(null); const slowdownFactorRef = React.useRef(SLOWDOWN_FACTOR_DEFAULT); const loadingPackRef = React.useRef(false); const mountedRef = React.useRef(false); React.useEffect(() => { mountedRef.current = true; let unlisten: null | (() => void) = null; const recreateRenderer = async ( pack: UiSpritePack, nextSnapshot: UiSnapshot ): Promise => { if (!mountedRef.current || hostRef.current === null) { return false; } const previousRenderer = rendererRef.current; const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot); nextRenderer.setAnimationSlowdownFactor(slowdownFactorRef.current); rendererRef.current = nextRenderer; activePackRef.current = pack; loadedPackKeyRef.current = nextSnapshot.active_sprite_pack; previousRenderer?.dispose(); return true; }; const tryFitWindow = async (scale: number): Promise => { try { return await fitWindowForScale(scale); } catch (err) { if (mountedRef.current) { setError(String(err)); } return null; } }; const syncEffectiveScale = async (snapshotScale: number, effectiveScale: number): Promise => { if (Math.abs(snapshotScale - effectiveScale) < SCALE_EPSILON) { return; } if ( effectiveScaleSyncRef.current !== null && Math.abs(effectiveScaleSyncRef.current - effectiveScale) < SCALE_EPSILON ) { return; } effectiveScaleSyncRef.current = effectiveScale; try { await invokeSetScale(effectiveScale); } catch (err) { if (mountedRef.current) { setError(String(err)); } } }; const processSnapshot = async (value: UiSnapshot): Promise => { if (!mountedRef.current) { return; } if ( effectiveScaleSyncRef.current !== null && Math.abs(effectiveScaleSyncRef.current - value.scale) < SCALE_EPSILON ) { effectiveScaleSyncRef.current = null; } setSnapshot(value); rendererRef.current?.applySnapshot(value); const activePack = activePackRef.current; const needsReload = activePack === null || loadedPackKeyRef.current !== value.active_sprite_pack; if (needsReload && !loadingPackRef.current) { loadingPackRef.current = true; let reloaded = false; try { const pack = await invoke("load_active_sprite_pack"); reloaded = await recreateRenderer(pack, value); if (reloaded) { const effectiveScale = await tryFitWindow(value.scale); if (effectiveScale !== null) { await syncEffectiveScale(value.scale, effectiveScale); } if (mountedRef.current && effectiveScale !== null) { setError(null); } } } catch (err) { if (mountedRef.current) { console.error("reload_pack_failed", err); setError(String(err)); } } finally { loadingPackRef.current = false; } } if (activePackRef.current === null) { return; } const effectiveScale = await tryFitWindow(value.scale); if (effectiveScale !== null) { await syncEffectiveScale(value.scale, effectiveScale); } if (effectiveScale !== null && mountedRef.current) { setError(null); } }; Promise.all([ invoke("load_active_sprite_pack"), invoke("current_state"), invoke("debug_overlay_visible"), invokeAnimationSlowdownFactor() ]) .then(async ([pack, initialSnapshot, showDebug, slowdownFactor]) => { if (!mountedRef.current) { return; } setDebugOverlayVisible(showDebug); slowdownFactorRef.current = Math.min( Math.max(Math.round(slowdownFactor), SLOWDOWN_FACTOR_MIN), SLOWDOWN_FACTOR_MAX ); await recreateRenderer(pack, initialSnapshot); await processSnapshot(initialSnapshot); const unlistenSnapshot = await listen("runtime:snapshot", (event) => { void processSnapshot(event.payload); }); const unlistenDebug = await listen("runtime:debug-overlay-visible", (event) => { if (!mountedRef.current) { return; } setDebugOverlayVisible(Boolean(event.payload)); }); const unlistenSlowdown = await listen( "runtime:animation-slowdown-factor", (event) => { if (!mountedRef.current) { return; } slowdownFactorRef.current = Math.min( Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN), SLOWDOWN_FACTOR_MAX ); rendererRef.current?.setAnimationSlowdownFactor(slowdownFactorRef.current); } ); unlisten = () => { unlistenSnapshot(); unlistenDebug(); unlistenSlowdown(); }; }) .catch((err) => { if (mountedRef.current) { setError(String(err)); } }); return () => { mountedRef.current = false; if (unlisten !== null) { unlisten(); } rendererRef.current?.dispose(); rendererRef.current = null; }; }, []); const toggleDebugOverlay = React.useCallback(async () => { try { const next = !debugOverlayVisible; const persisted = await invoke("set_debug_overlay_visible", { visible: next }); setDebugOverlayVisible(persisted); } catch (err) { setError(String(err)); } }, [debugOverlayVisible]); React.useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { if (!event.ctrlKey || !event.shiftKey || event.code !== "KeyD") { return; } event.preventDefault(); void toggleDebugOverlay(); }; window.addEventListener("keydown", onKeyDown); return () => { window.removeEventListener("keydown", onKeyDown); }; }, [toggleDebugOverlay]); const onMouseDown = React.useCallback((event: React.MouseEvent) => { if (event.button !== 0) { return; } void getCurrentWindow().startDragging().catch((err) => { setError(String(err)); }); }, []); return (
{error !== null && !debugOverlayVisible ?

{error}

: null} {debugOverlayVisible ? (

sprimo-tauri

Toggle: Ctrl+Shift+D

{error !== null ?

{error}

: null} {snapshot === null ? (

Loading snapshot...

) : (
state
{snapshot.state}
animation
{snapshot.current_animation}
pack
{snapshot.active_sprite_pack}
position
{snapshot.x}, {snapshot.y}
scale
{snapshot.scale}
)}
) : null}
); } function SettingsWindow(): JSX.Element { const [settings, setSettings] = React.useState(null); const [packs, setPacks] = React.useState([]); const [tokens, setTokens] = React.useState([]); const [tokenDrafts, setTokenDrafts] = React.useState>({}); const [newTokenLabel, setNewTokenLabel] = React.useState(""); const [activePack, setActivePack] = React.useState(null); const [error, setError] = React.useState(null); const [pending, setPending] = React.useState(false); React.useEffect(() => { let unlisten: null | (() => void) = null; let mounted = true; Promise.all([ invoke("settings_snapshot"), invoke("list_sprite_packs"), invoke("load_active_sprite_pack"), invokeListApiTokens() ]) .then(async ([snapshot, options, pack, authTokens]) => { if (!mounted) { return; } setSettings(snapshot); setPacks(options); setActivePack(pack); setTokens(authTokens); setTokenDrafts( authTokens.reduce>((acc, token) => { acc[token.id] = token.label; return acc; }, {}) ); unlisten = await listen("runtime:snapshot", (event) => { if (!mounted) { return; } const payload = event.payload; if (payload.active_sprite_pack !== activePack?.id) { void invoke("load_active_sprite_pack") .then((nextPack) => { if (mounted) { setActivePack(nextPack); } }) .catch(() => { // Keep existing pack metadata if reload fails. }); } setSettings((prev) => { if (prev === null) { return prev; } return { active_sprite_pack: payload.active_sprite_pack, scale: payload.scale, visible: payload.visible, always_on_top: payload.always_on_top, tauri_animation_slowdown_factor: prev.tauri_animation_slowdown_factor ?? SLOWDOWN_FACTOR_DEFAULT }; }); }); const unlistenSlowdown = await listen("runtime:animation-slowdown-factor", (event) => { if (!mounted) { return; } const factor = Math.min( Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN), SLOWDOWN_FACTOR_MAX ); setSettings((prev) => prev === null ? prev : { ...prev, tauri_animation_slowdown_factor: factor } ); }); const previousUnlisten = unlisten; unlisten = () => { previousUnlisten(); unlistenSlowdown(); }; }) .catch((err) => { if (mounted) { setError(String(err)); } }); return () => { mounted = false; if (unlisten !== null) { unlisten(); } }; }, [activePack?.id]); const withPending = React.useCallback(async (fn: () => Promise): Promise => { setPending(true); setError(null); try { return await fn(); } catch (err) { setError(String(err)); return null; } finally { setPending(false); } }, []); const onPackChange = React.useCallback( async (event: React.ChangeEvent) => { const value = event.target.value; const next = await withPending(() => invokeSetSpritePack(value)); if (next === null) { return; } const refreshedPack = await withPending(() => invoke("load_active_sprite_pack")); if (refreshedPack !== null) { setActivePack(refreshedPack); } setSettings((prev) => prev === null ? prev : { ...prev, active_sprite_pack: next.active_sprite_pack } ); }, [withPending] ); const onScaleChange = React.useCallback( async (event: React.ChangeEvent) => { const value = Number(event.target.value); if (!Number.isFinite(value)) { return; } const next = await withPending(() => invokeSetScale(value)); if (next === null) { return; } setSettings((prev) => (prev === null ? prev : { ...prev, scale: next.scale })); }, [withPending] ); const onVisibleChange = React.useCallback( async (event: React.ChangeEvent) => { const value = event.target.checked; const next = await withPending(() => invokeSetVisibility(value)); if (next === null) { return; } setSettings((prev) => (prev === null ? prev : { ...prev, visible: value })); }, [withPending] ); const onAlwaysOnTopChange = React.useCallback( async (event: React.ChangeEvent) => { const value = event.target.checked; const next = await withPending(() => invokeSetAlwaysOnTop(value)); if (next === null) { return; } setSettings((prev) => (prev === null ? prev : { ...prev, always_on_top: value })); }, [withPending] ); const onAnimationSlowdownFactorChange = React.useCallback( async (event: React.ChangeEvent) => { const value = Number(event.target.value); if (!Number.isFinite(value)) { return; } const clamped = Math.min( Math.max(Math.round(value), SLOWDOWN_FACTOR_MIN), SLOWDOWN_FACTOR_MAX ); const persisted = await withPending(() => invokeSetAnimationSlowdownFactor(clamped)); if (persisted === null) { return; } setSettings((prev) => prev === null ? prev : { ...prev, tauri_animation_slowdown_factor: Number(persisted) } ); }, [withPending] ); const onNewTokenLabelChange = React.useCallback( (event: React.ChangeEvent) => { setNewTokenLabel(event.target.value); }, [] ); const onCreateToken = React.useCallback(async () => { const created = await withPending(() => invokeCreateApiToken(newTokenLabel || undefined)); if (created === null) { return; } const refreshed = await withPending(() => invokeListApiTokens()); if (refreshed === null) { return; } setTokens(refreshed); setTokenDrafts( refreshed.reduce>((acc, token) => { acc[token.id] = token.label; return acc; }, {}) ); setNewTokenLabel(""); }, [newTokenLabel, withPending]); const onTokenDraftChange = React.useCallback((id: string, value: string) => { setTokenDrafts((prev) => ({ ...prev, [id]: value })); }, []); const onRenameToken = React.useCallback( async (id: string) => { const nextLabel = tokenDrafts[id] ?? ""; const refreshed = await withPending(() => invokeRenameApiToken(id, nextLabel)); if (refreshed === null) { return; } setTokens(refreshed); setTokenDrafts( refreshed.reduce>((acc, token) => { acc[token.id] = token.label; return acc; }, {}) ); }, [tokenDrafts, withPending] ); const onCopyToken = React.useCallback(async (token: string) => { try { await navigator.clipboard.writeText(token); } catch (err) { setError(String(err)); } }, []); const onRevokeToken = React.useCallback( async (id: string) => { if (!window.confirm("Revoke this API token?")) { return; } const refreshed = await withPending(() => invokeRevokeApiToken(id)); if (refreshed === null) { return; } setTokens(refreshed); setTokenDrafts( refreshed.reduce>((acc, token) => { acc[token.id] = token.label; return acc; }, {}) ); }, [withPending] ); return (

Settings

Character and window controls

{error !== null ?

{error}

: null} {settings === null ? (

Loading settings...

) : ( <>

API Tokens

Use any listed token as `Authorization: Bearer <token>`.

{tokens.map((entry) => (
))}
)}
); } function AppRoot(): JSX.Element { const windowLabel = getCurrentWindow().label; if (windowLabel === "settings") { return ; } return ; } ReactDOM.createRoot(document.getElementById("root")!).render( );