Add: setting window for tauri - bugs not fixed yet

This commit is contained in:
DaZuo0122
2026-02-14 13:21:56 +08:00
parent 907974e61f
commit 901bf0ffc3
13 changed files with 1105 additions and 96 deletions

View File

@@ -6,11 +6,39 @@ import { LogicalPosition, LogicalSize, getCurrentWindow } from "@tauri-apps/api/
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;
};
type UiSpritePackOption = {
id: string;
pack_id_or_path: string;
};
const WINDOW_PADDING = 16;
const MIN_WINDOW_SIZE = 64;
const SIZE_EPSILON = 0.5;
const SCALE_EPSILON = 0.0001;
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 });
}
function fittedWindowSize(
frameWidth: number,
frameHeight: number,
@@ -24,10 +52,7 @@ function fittedWindowSize(
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<void> {
const window = getCurrentWindow();
const [outerPosition, innerSize] = await Promise.all([
window.outerPosition(),
window.innerSize()
]);
const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]);
const target = fittedWindowSize(pack.frame_width, pack.frame_height, scale);
const widthChanged = Math.abs(target.width - innerSize.width) > SIZE_EPSILON;
@@ -45,85 +70,133 @@ async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<voi
await window.setPosition(new LogicalPosition(targetX, targetY));
}
function App(): JSX.Element {
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 scaleFitRef = React.useRef<number | null>(null);
const loadingPackRef = React.useRef(false);
const mountedRef = React.useRef(false);
React.useEffect(() => {
mountedRef.current = true;
let unlisten: null | (() => void) = null;
let mounted = true;
let activePack: UiSpritePack | null = 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);
rendererRef.current = nextRenderer;
activePackRef.current = pack;
loadedPackKeyRef.current = nextSnapshot.active_sprite_pack;
previousRenderer?.dispose();
return true;
};
const tryFitWindow = async (pack: UiSpritePack, scale: number): Promise<boolean> => {
try {
await fitWindowForScale(pack, scale);
scaleFitRef.current = scale;
return true;
} catch (err) {
if (mountedRef.current) {
setError(String(err));
}
return false;
}
};
const processSnapshot = async (value: UiSnapshot): Promise<void> => {
if (!mountedRef.current) {
return;
}
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) {
await tryFitWindow(pack, value.scale);
if (mountedRef.current) {
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;
}
if (
scaleFitRef.current !== null &&
Math.abs(scaleFitRef.current - value.scale) < SCALE_EPSILON
) {
return;
}
const fitApplied = await tryFitWindow(activePackRef.current, value.scale);
if (fitApplied && mountedRef.current) {
setError(null);
}
};
Promise.all([
invoke<UiSpritePack>("load_active_sprite_pack"),
invoke<UiSnapshot>("current_state"),
invoke<boolean>("debug_overlay_visible")
])
.then(async ([pack, initialSnapshot, showDebug]) => {
if (!mounted) {
if (!mountedRef.current) {
return;
}
activePack = pack;
setDebugOverlayVisible(showDebug);
setSnapshot(initialSnapshot);
if (hostRef.current !== null) {
rendererRef.current = await PixiPetRenderer.create(
hostRef.current,
pack,
initialSnapshot
);
}
scaleFitRef.current = initialSnapshot.scale;
await fitWindowForScale(pack, initialSnapshot.scale);
await recreateRenderer(pack, initialSnapshot);
await processSnapshot(initialSnapshot);
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) {
return;
}
const value = event.payload;
setSnapshot(value);
rendererRef.current?.applySnapshot(value);
if (activePack === null) {
return;
}
if (
scaleFitRef.current !== null &&
Math.abs(scaleFitRef.current - value.scale) < SCALE_EPSILON
) {
return;
}
scaleFitRef.current = value.scale;
void fitWindowForScale(activePack, value.scale).catch((err) => {
if (mounted) {
setError(String(err));
}
});
const unlistenSnapshot = await listen<UiSnapshot>("runtime:snapshot", (event) => {
void processSnapshot(event.payload);
});
const unlistenDebug = await listen<boolean>(
"runtime:debug-overlay-visible",
(event) => {
if (!mounted) {
return;
}
setDebugOverlayVisible(Boolean(event.payload));
const unlistenDebug = await listen<boolean>("runtime:debug-overlay-visible", (event) => {
if (!mountedRef.current) {
return;
}
);
const previousUnlisten = unlisten;
setDebugOverlayVisible(Boolean(event.payload));
});
unlisten = () => {
previousUnlisten();
unlistenSnapshot();
unlistenDebug();
};
})
.catch((err) => {
if (mounted) {
if (mountedRef.current) {
setError(String(err));
}
});
return () => {
mounted = false;
mountedRef.current = false;
if (unlisten !== null) {
unlisten();
}
@@ -168,7 +241,7 @@ function App(): JSX.Element {
}, []);
return (
<main className="app" onMouseDown={onMouseDown}>
<main className="app overlay-app" onMouseDown={onMouseDown}>
<div className="canvas-host" ref={hostRef} />
{error !== null && !debugOverlayVisible ? <p className="error-banner">{error}</p> : null}
{debugOverlayVisible ? (
@@ -200,8 +273,198 @@ function App(): JSX.Element {
);
}
function SettingsWindow(): JSX.Element {
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
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")
])
.then(async ([snapshot, options]) => {
if (!mounted) {
return;
}
setSettings(snapshot);
setPacks(options);
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) {
return;
}
const payload = event.payload;
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
};
});
});
})
.catch((err) => {
if (mounted) {
setError(String(err));
}
});
return () => {
mounted = false;
if (unlisten !== null) {
unlisten();
}
};
}, []);
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;
}
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]
);
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={0.5}
max={3.0}
step={0.05}
value={settings.scale}
disabled={pending}
onChange={onScaleChange}
/>
</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>
</main>
);
}
function AppRoot(): JSX.Element {
const windowLabel = getCurrentWindow().label;
if (windowLabel === "settings") {
return <SettingsWindow />;
}
return <MainOverlayWindow />;
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<AppRoot />
</React.StrictMode>
);