Files
Sprimo/frontend/tauri-ui/src/main.tsx
2026-02-15 12:20:00 +08:00

840 lines
27 KiB
TypeScript

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<UiSnapshot> {
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
}
async function invokeSetScale(scale: number): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_scale", { scale });
}
async function invokeSetVisibility(visible: boolean): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_visibility", { visible });
}
async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_always_on_top", { alwaysOnTop });
}
async function invokeAnimationSlowdownFactor(): Promise<number> {
return invoke<number>("tauri_animation_slowdown_factor");
}
async function invokeSetAnimationSlowdownFactor(factor: number): Promise<number> {
return invoke<number>("set_tauri_animation_slowdown_factor", { factor });
}
async function invokeListApiTokens(): Promise<UiApiToken[]> {
return invoke<UiApiToken[]>("list_api_tokens");
}
async function invokeCreateApiToken(label?: string): Promise<UiApiToken> {
return invoke<UiApiToken>("create_api_token", { label });
}
async function invokeRenameApiToken(id: string, label: string): Promise<UiApiToken[]> {
return invoke<UiApiToken[]>("rename_api_token", { id, label });
}
async function invokeRevokeApiToken(id: string): Promise<UiApiToken[]> {
return invoke<UiApiToken[]>("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<number> {
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<UiSnapshot | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [debugOverlayVisible, setDebugOverlayVisible] = React.useState(false);
const hostRef = React.useRef<HTMLDivElement | null>(null);
const rendererRef = React.useRef<PixiPetRenderer | null>(null);
const activePackRef = React.useRef<UiSpritePack | null>(null);
const loadedPackKeyRef = React.useRef<string | null>(null);
const effectiveScaleSyncRef = React.useRef<number | null>(null);
const slowdownFactorRef = React.useRef<number>(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<boolean> => {
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<number | null> => {
try {
return await fitWindowForScale(scale);
} catch (err) {
if (mountedRef.current) {
setError(String(err));
}
return null;
}
};
const syncEffectiveScale = async (snapshotScale: number, effectiveScale: number): Promise<void> => {
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<void> => {
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<UiSpritePack>("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<UiSpritePack>("load_active_sprite_pack"),
invoke<UiSnapshot>("current_state"),
invoke<boolean>("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<UiSnapshot>("runtime:snapshot", (event) => {
void processSnapshot(event.payload);
});
const unlistenDebug = await listen<boolean>("runtime:debug-overlay-visible", (event) => {
if (!mountedRef.current) {
return;
}
setDebugOverlayVisible(Boolean(event.payload));
});
const unlistenSlowdown = await listen<number>(
"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<boolean>("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<HTMLElement>) => {
if (event.button !== 0) {
return;
}
void getCurrentWindow().startDragging().catch((err) => {
setError(String(err));
});
}, []);
return (
<main className="app overlay-app" onMouseDown={onMouseDown}>
<div className="canvas-host" ref={hostRef} />
{error !== null && !debugOverlayVisible ? <p className="error-banner">{error}</p> : null}
{debugOverlayVisible ? (
<section className="debug-panel">
<h1>sprimo-tauri</h1>
<p className="hint">Toggle: Ctrl+Shift+D</p>
{error !== null ? <p className="error">{error}</p> : null}
{snapshot === null ? (
<p>Loading snapshot...</p>
) : (
<dl>
<dt>state</dt>
<dd>{snapshot.state}</dd>
<dt>animation</dt>
<dd>{snapshot.current_animation}</dd>
<dt>pack</dt>
<dd>{snapshot.active_sprite_pack}</dd>
<dt>position</dt>
<dd>
{snapshot.x}, {snapshot.y}
</dd>
<dt>scale</dt>
<dd>{snapshot.scale}</dd>
</dl>
)}
</section>
) : null}
</main>
);
}
function SettingsWindow(): JSX.Element {
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
const [tokens, setTokens] = React.useState<UiApiToken[]>([]);
const [tokenDrafts, setTokenDrafts] = React.useState<Record<string, string>>({});
const [newTokenLabel, setNewTokenLabel] = React.useState("");
const [activePack, setActivePack] = React.useState<UiSpritePack | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [pending, setPending] = React.useState(false);
React.useEffect(() => {
let unlisten: null | (() => void) = null;
let mounted = true;
Promise.all([
invoke<UiSettingsSnapshot>("settings_snapshot"),
invoke<UiSpritePackOption[]>("list_sprite_packs"),
invoke<UiSpritePack>("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<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) {
return;
}
const payload = event.payload;
if (payload.active_sprite_pack !== activePack?.id) {
void invoke<UiSpritePack>("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<number>("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 <T,>(fn: () => Promise<T>): Promise<T | null> => {
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<HTMLSelectElement>) => {
const value = event.target.value;
const next = await withPending(() => invokeSetSpritePack(value));
if (next === null) {
return;
}
const refreshedPack = await withPending(() => invoke<UiSpritePack>("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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<Record<string, string>>((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<Record<string, string>>((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<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
},
[withPending]
);
return (
<main className="settings-root">
<section className="settings-card">
<h1>Settings</h1>
<p className="settings-subtitle">Character and window controls</p>
{error !== null ? <p className="settings-error">{error}</p> : null}
{settings === null ? (
<p>Loading settings...</p>
) : (
<>
<label className="field">
<span>Character</span>
<select
value={settings.active_sprite_pack}
disabled={pending}
onChange={onPackChange}
>
{packs.map((pack) => (
<option key={pack.pack_id_or_path} value={pack.pack_id_or_path}>
{pack.id}
</option>
))}
</select>
</label>
<label className="field">
<span>Scale: {settings.scale.toFixed(2)}x</span>
<input
type="range"
min={SCALE_MIN}
max={SCALE_MAX}
step={0.05}
value={settings.scale}
disabled={pending}
onChange={onScaleChange}
/>
</label>
<label className="field">
<span>
Animation Speed Factor: x{settings.tauri_animation_slowdown_factor}
</span>
<input
type="range"
min={SLOWDOWN_FACTOR_MIN}
max={SLOWDOWN_FACTOR_MAX}
step={1}
value={settings.tauri_animation_slowdown_factor}
disabled={pending}
onChange={onAnimationSlowdownFactorChange}
/>
</label>
<label className="toggle">
<input
type="checkbox"
checked={settings.visible}
disabled={pending}
onChange={onVisibleChange}
/>
<span>Visible</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={settings.always_on_top}
disabled={pending}
onChange={onAlwaysOnTopChange}
/>
<span>Always on top</span>
</label>
<section className="token-section">
<h2>API Tokens</h2>
<p className="token-help">
Use any listed token as `Authorization: Bearer &lt;token&gt;`.
</p>
<div className="token-create">
<input
type="text"
value={newTokenLabel}
placeholder="New token label"
disabled={pending}
onChange={onNewTokenLabelChange}
/>
<button type="button" disabled={pending} onClick={onCreateToken}>
Create token
</button>
</div>
<div className="token-list">
{tokens.map((entry) => (
<article className="token-item" key={entry.id}>
<label className="field">
<span>Label</span>
<input
type="text"
value={tokenDrafts[entry.id] ?? ""}
disabled={pending}
onChange={(event) =>
onTokenDraftChange(entry.id, event.target.value)
}
/>
</label>
<label className="field">
<span>Token</span>
<input type="text" value={entry.token} readOnly />
</label>
<div className="token-actions">
<button
type="button"
disabled={pending}
onClick={() => onCopyToken(entry.token)}
>
Copy
</button>
<button
type="button"
disabled={pending}
onClick={() => onRenameToken(entry.id)}
>
Save label
</button>
<button
type="button"
disabled={pending || tokens.length <= 1}
onClick={() => onRevokeToken(entry.id)}
>
Revoke
</button>
</div>
</article>
))}
</div>
</section>
</>
)}
</section>
</main>
);
}
function AppRoot(): JSX.Element {
const windowLabel = getCurrentWindow().label;
if (windowLabel === "settings") {
return <SettingsWindow />;
}
return <MainOverlayWindow />;
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AppRoot />
</React.StrictMode>
);