Add: setting window for tauri - bugs not fixed yet
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ export type UiSnapshot = {
|
||||
y: number;
|
||||
scale: number;
|
||||
active_sprite_pack: string;
|
||||
visible: boolean;
|
||||
always_on_top: boolean;
|
||||
};
|
||||
|
||||
type AnimationMap = Map<string, UiAnimationClip>;
|
||||
@@ -41,6 +43,7 @@ export class PixiPetRenderer {
|
||||
private frameCursor = 0;
|
||||
private frameElapsedMs = 0;
|
||||
private baseTexture: BaseTexture;
|
||||
private disposed = false;
|
||||
|
||||
private constructor(
|
||||
app: Application,
|
||||
@@ -67,8 +70,6 @@ export class PixiPetRenderer {
|
||||
antialias: true,
|
||||
resizeTo: container
|
||||
});
|
||||
container.replaceChildren(app.view as HTMLCanvasElement);
|
||||
|
||||
const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url);
|
||||
if (baseTexture.width <= 0 || baseTexture.height <= 0) {
|
||||
throw new Error("Atlas image loaded with invalid dimensions.");
|
||||
@@ -76,6 +77,7 @@ export class PixiPetRenderer {
|
||||
const sprite = new Sprite();
|
||||
sprite.anchor.set(pack.anchor.x, pack.anchor.y);
|
||||
app.stage.addChild(sprite);
|
||||
container.replaceChildren(app.view as HTMLCanvasElement);
|
||||
|
||||
const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture);
|
||||
renderer.layoutSprite();
|
||||
@@ -102,13 +104,25 @@ export class PixiPetRenderer {
|
||||
const keyR = 0xff;
|
||||
const keyG = 0x00;
|
||||
const keyB = 0xff;
|
||||
const tolerance = 28;
|
||||
const hardTolerance = 22;
|
||||
const softTolerance = 46;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const dr = Math.abs(data[i] - keyR);
|
||||
const dg = Math.abs(data[i + 1] - keyG);
|
||||
const db = Math.abs(data[i + 2] - keyB);
|
||||
if (dr <= tolerance && dg <= tolerance && db <= tolerance) {
|
||||
const maxDistance = Math.max(dr, dg, db);
|
||||
if (maxDistance <= hardTolerance) {
|
||||
data[i + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
if (maxDistance <= softTolerance) {
|
||||
const alphaScale =
|
||||
(maxDistance - hardTolerance) / (softTolerance - hardTolerance);
|
||||
const suppress = 1 - alphaScale;
|
||||
data[i + 3] = Math.round(data[i + 3] * alphaScale);
|
||||
// Remove magenta spill from antialiased edges after alpha reduction.
|
||||
data[i] = Math.round(data[i] * (1 - 0.4 * suppress));
|
||||
data[i + 2] = Math.round(data[i + 2] * (1 - 0.4 * suppress));
|
||||
}
|
||||
}
|
||||
ctx.putImageData(frame, 0, 0);
|
||||
@@ -122,13 +136,10 @@ export class PixiPetRenderer {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.app.ticker.stop();
|
||||
this.app.ticker.destroy();
|
||||
this.sprite.destroy({
|
||||
children: true,
|
||||
texture: false,
|
||||
baseTexture: false
|
||||
});
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
this.app.destroy(true, {
|
||||
children: true,
|
||||
texture: false,
|
||||
@@ -137,6 +148,9 @@ export class PixiPetRenderer {
|
||||
}
|
||||
|
||||
applySnapshot(snapshot: UiSnapshot): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const nextClip = this.resolveClip(snapshot.current_animation);
|
||||
if (nextClip.name !== this.currentClip.name) {
|
||||
this.currentClip = nextClip;
|
||||
@@ -150,6 +164,9 @@ export class PixiPetRenderer {
|
||||
|
||||
private startTicker(): void {
|
||||
this.app.ticker.add((ticker) => {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.layoutSprite();
|
||||
const frameMs = 1000 / Math.max(this.currentClip.fps, 1);
|
||||
this.frameElapsedMs += ticker.deltaMS;
|
||||
|
||||
@@ -73,3 +73,76 @@ dd {
|
||||
color: #fee2e2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
|
||||
color: #0f172a;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.settings-card h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-error {
|
||||
margin: 0;
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.field select,
|
||||
.field input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field select {
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user