MVP tauri frontend - Windows #1
@@ -113,7 +113,8 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
|
|||||||
- no frontend runtime exception (including `TypeError`) is allowed
|
- no frontend runtime exception (including `TypeError`) is allowed
|
||||||
- scaling behavior remains responsive after each pack switch
|
- scaling behavior remains responsive after each pack switch
|
||||||
8. Chroma-key quality check:
|
8. Chroma-key quality check:
|
||||||
- verify no visible magenta (`#FF00FF`) fringe remains around sprite edges in normal runtime view
|
- verify no visible magenta background/fringe remains around sprite edges in normal runtime view,
|
||||||
|
including non-exact gradient magenta atlas backgrounds (for example `ferris`/`demogorgon`)
|
||||||
9. Scale anchor and bounds check:
|
9. Scale anchor and bounds check:
|
||||||
- repeated scale changes should keep window centered without directional drift
|
- repeated scale changes should keep window centered without directional drift
|
||||||
- window must remain within current monitor bounds during scale adjustments
|
- window must remain within current monitor bounds during scale adjustments
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ type UiSpritePackOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const WINDOW_PADDING = 16;
|
const WINDOW_PADDING = 16;
|
||||||
|
const WINDOW_WORKAREA_MARGIN = 80;
|
||||||
const MIN_WINDOW_SIZE = 64;
|
const MIN_WINDOW_SIZE = 64;
|
||||||
const SIZE_EPSILON = 0.5;
|
const SIZE_EPSILON = 0.5;
|
||||||
const SCALE_EPSILON = 0.0001;
|
const SCALE_EPSILON = 0.0001;
|
||||||
|
const SCALE_MIN = 0.5;
|
||||||
|
const SCALE_MAX = 3.0;
|
||||||
|
|
||||||
async function invokeSetSpritePack(packIdOrPath: string): Promise<UiSnapshot> {
|
async function invokeSetSpritePack(packIdOrPath: string): Promise<UiSnapshot> {
|
||||||
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
|
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
|
||||||
@@ -56,6 +59,24 @@ function fittedWindowSize(
|
|||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maxVisibleScale(
|
||||||
|
pack: UiSpritePack,
|
||||||
|
workArea: { width: number; height: number }
|
||||||
|
): number {
|
||||||
|
const availableWidth = Math.max(
|
||||||
|
workArea.width - WINDOW_PADDING - WINDOW_WORKAREA_MARGIN,
|
||||||
|
MIN_WINDOW_SIZE
|
||||||
|
);
|
||||||
|
const availableHeight = Math.max(
|
||||||
|
workArea.height - WINDOW_PADDING - WINDOW_WORKAREA_MARGIN,
|
||||||
|
MIN_WINDOW_SIZE
|
||||||
|
);
|
||||||
|
const maxByWidth = availableWidth / Math.max(pack.frame_width, 1);
|
||||||
|
const maxByHeight = availableHeight / Math.max(pack.frame_height, 1);
|
||||||
|
const cap = Math.min(maxByWidth, maxByHeight);
|
||||||
|
return Math.max(SCALE_MIN, Math.min(cap, SCALE_MAX));
|
||||||
|
}
|
||||||
|
|
||||||
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<void> {
|
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<void> {
|
||||||
const window = getCurrentWindow();
|
const window = getCurrentWindow();
|
||||||
const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]);
|
const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]);
|
||||||
@@ -81,8 +102,16 @@ async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (monitor !== null) {
|
if (monitor !== null) {
|
||||||
targetWidth = Math.min(targetWidth, monitor.workArea.size.width);
|
const widthCap = Math.max(
|
||||||
targetHeight = Math.min(targetHeight, monitor.workArea.size.height);
|
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;
|
targetX = centerX - targetWidth / 2;
|
||||||
targetY = centerY - targetHeight / 2;
|
targetY = centerY - targetHeight / 2;
|
||||||
}
|
}
|
||||||
@@ -311,6 +340,7 @@ function MainOverlayWindow(): JSX.Element {
|
|||||||
function SettingsWindow(): JSX.Element {
|
function SettingsWindow(): JSX.Element {
|
||||||
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
|
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
|
||||||
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
|
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
|
||||||
|
const [activePack, setActivePack] = React.useState<UiSpritePack | null>(null);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [pending, setPending] = React.useState(false);
|
const [pending, setPending] = React.useState(false);
|
||||||
|
|
||||||
@@ -319,19 +349,32 @@ function SettingsWindow(): JSX.Element {
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
invoke<UiSettingsSnapshot>("settings_snapshot"),
|
invoke<UiSettingsSnapshot>("settings_snapshot"),
|
||||||
invoke<UiSpritePackOption[]>("list_sprite_packs")
|
invoke<UiSpritePackOption[]>("list_sprite_packs"),
|
||||||
|
invoke<UiSpritePack>("load_active_sprite_pack")
|
||||||
])
|
])
|
||||||
.then(async ([snapshot, options]) => {
|
.then(async ([snapshot, options, pack]) => {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSettings(snapshot);
|
setSettings(snapshot);
|
||||||
setPacks(options);
|
setPacks(options);
|
||||||
|
setActivePack(pack);
|
||||||
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = event.payload;
|
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) => {
|
setSettings((prev) => {
|
||||||
if (prev === null) {
|
if (prev === null) {
|
||||||
return prev;
|
return prev;
|
||||||
@@ -356,7 +399,7 @@ function SettingsWindow(): JSX.Element {
|
|||||||
unlisten();
|
unlisten();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [activePack?.id]);
|
||||||
|
|
||||||
const withPending = React.useCallback(async <T,>(fn: () => Promise<T>): Promise<T | null> => {
|
const withPending = React.useCallback(async <T,>(fn: () => Promise<T>): Promise<T | null> => {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
@@ -378,6 +421,10 @@ function SettingsWindow(): JSX.Element {
|
|||||||
if (next === null) {
|
if (next === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const refreshedPack = await withPending(() => invoke<UiSpritePack>("load_active_sprite_pack"));
|
||||||
|
if (refreshedPack !== null) {
|
||||||
|
setActivePack(refreshedPack);
|
||||||
|
}
|
||||||
setSettings((prev) =>
|
setSettings((prev) =>
|
||||||
prev === null
|
prev === null
|
||||||
? prev
|
? prev
|
||||||
@@ -396,13 +443,30 @@ function SettingsWindow(): JSX.Element {
|
|||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = await withPending(() => invokeSetScale(value));
|
let effectiveScale = value;
|
||||||
|
if (activePack !== null) {
|
||||||
|
try {
|
||||||
|
const monitor = await currentMonitor();
|
||||||
|
if (monitor !== null) {
|
||||||
|
effectiveScale = Math.min(
|
||||||
|
value,
|
||||||
|
maxVisibleScale(activePack, {
|
||||||
|
width: monitor.workArea.size.width,
|
||||||
|
height: monitor.workArea.size.height
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep requested value if monitor query fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = await withPending(() => invokeSetScale(effectiveScale));
|
||||||
if (next === null) {
|
if (next === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSettings((prev) => (prev === null ? prev : { ...prev, scale: next.scale }));
|
setSettings((prev) => (prev === null ? prev : { ...prev, scale: next.scale }));
|
||||||
},
|
},
|
||||||
[withPending]
|
[activePack, withPending]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onVisibleChange = React.useCallback(
|
const onVisibleChange = React.useCallback(
|
||||||
@@ -457,8 +521,8 @@ function SettingsWindow(): JSX.Element {
|
|||||||
<span>Scale: {settings.scale.toFixed(2)}x</span>
|
<span>Scale: {settings.scale.toFixed(2)}x</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0.5}
|
min={SCALE_MIN}
|
||||||
max={3.0}
|
max={SCALE_MAX}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
value={settings.scale}
|
value={settings.scale}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ const FALLBACK_HUE_MIN = 255;
|
|||||||
const FALLBACK_HUE_MAX = 355;
|
const FALLBACK_HUE_MAX = 355;
|
||||||
const FALLBACK_SAT_MIN = 0.15;
|
const FALLBACK_SAT_MIN = 0.15;
|
||||||
const FALLBACK_VAL_MIN = 0.04;
|
const FALLBACK_VAL_MIN = 0.04;
|
||||||
|
const STRONG_MAGENTA_RB_MIN = 72;
|
||||||
|
const STRONG_MAGENTA_DOMINANCE = 24;
|
||||||
const HALO_HUE_MIN = 245;
|
const HALO_HUE_MIN = 245;
|
||||||
const HALO_HUE_MAX = 355;
|
const HALO_HUE_MAX = 355;
|
||||||
const HALO_SAT_MIN = 0.15;
|
const HALO_SAT_MIN = 0.15;
|
||||||
@@ -148,6 +150,16 @@ export class PixiPetRenderer {
|
|||||||
PixiPetRenderer.isHueInRange(h, CONNECTED_HUE_MIN, CONNECTED_HUE_MAX) &&
|
PixiPetRenderer.isHueInRange(h, CONNECTED_HUE_MIN, CONNECTED_HUE_MAX) &&
|
||||||
s >= CONNECTED_SAT_MIN &&
|
s >= CONNECTED_SAT_MIN &&
|
||||||
v >= CONNECTED_VAL_MIN
|
v >= CONNECTED_VAL_MIN
|
||||||
|
) {
|
||||||
|
isKeyLike[idx] = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
PixiPetRenderer.isStrongMagentaFamily(
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
data[offset + 2]
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
isKeyLike[idx] = 1;
|
isKeyLike[idx] = 1;
|
||||||
}
|
}
|
||||||
@@ -212,6 +224,11 @@ export class PixiPetRenderer {
|
|||||||
(PixiPetRenderer.isHueInRange(h, FALLBACK_HUE_MIN, FALLBACK_HUE_MAX) &&
|
(PixiPetRenderer.isHueInRange(h, FALLBACK_HUE_MIN, FALLBACK_HUE_MAX) &&
|
||||||
s >= FALLBACK_SAT_MIN &&
|
s >= FALLBACK_SAT_MIN &&
|
||||||
v >= FALLBACK_VAL_MIN) ||
|
v >= FALLBACK_VAL_MIN) ||
|
||||||
|
PixiPetRenderer.isStrongMagentaFamily(
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
data[offset + 2]
|
||||||
|
) ||
|
||||||
maxDistanceFromHardKey <= 96
|
maxDistanceFromHardKey <= 96
|
||||||
) {
|
) {
|
||||||
data[offset + 3] = 0;
|
data[offset + 3] = 0;
|
||||||
@@ -219,6 +236,69 @@ export class PixiPetRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deterministic last pass: remove any border-connected magenta-family background.
|
||||||
|
head = 0;
|
||||||
|
tail = 0;
|
||||||
|
removedBg.fill(0);
|
||||||
|
const enqueueIfMagentaBorder = (x: number, y: number): void => {
|
||||||
|
const idx = indexFor(x, y);
|
||||||
|
if (removedBg[idx] === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const offset = channelOffset(idx);
|
||||||
|
if (data[offset + 3] === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!PixiPetRenderer.isStrongMagentaFamily(
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
data[offset + 2]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removedBg[idx] = 1;
|
||||||
|
queue[tail] = idx;
|
||||||
|
tail += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
enqueueIfMagentaBorder(x, 0);
|
||||||
|
enqueueIfMagentaBorder(x, height - 1);
|
||||||
|
}
|
||||||
|
for (let y = 1; y < height - 1; y += 1) {
|
||||||
|
enqueueIfMagentaBorder(0, y);
|
||||||
|
enqueueIfMagentaBorder(width - 1, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (head < tail) {
|
||||||
|
const idx = queue[head];
|
||||||
|
head += 1;
|
||||||
|
const x = idx % width;
|
||||||
|
const y = Math.floor(idx / width);
|
||||||
|
if (x > 0) {
|
||||||
|
enqueueIfMagentaBorder(x - 1, y);
|
||||||
|
}
|
||||||
|
if (x + 1 < width) {
|
||||||
|
enqueueIfMagentaBorder(x + 1, y);
|
||||||
|
}
|
||||||
|
if (y > 0) {
|
||||||
|
enqueueIfMagentaBorder(x, y - 1);
|
||||||
|
}
|
||||||
|
if (y + 1 < height) {
|
||||||
|
enqueueIfMagentaBorder(x, y + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let idx = 0; idx < pixelCount; idx += 1) {
|
||||||
|
if (removedBg[idx] !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const offset = channelOffset(idx);
|
||||||
|
data[offset + 3] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
for (let y = 0; y < height; y += 1) {
|
for (let y = 0; y < height; y += 1) {
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
const idx = indexFor(x, y);
|
const idx = indexFor(x, y);
|
||||||
@@ -314,6 +394,15 @@ export class PixiPetRenderer {
|
|||||||
return hue >= min || hue <= max;
|
return hue >= min || hue <= max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static isStrongMagentaFamily(r: number, g: number, b: number): boolean {
|
||||||
|
const minRb = Math.min(r, b);
|
||||||
|
return (
|
||||||
|
r >= STRONG_MAGENTA_RB_MIN &&
|
||||||
|
b >= STRONG_MAGENTA_RB_MIN &&
|
||||||
|
g + STRONG_MAGENTA_DOMINANCE <= minRb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this.disposed) {
|
if (this.disposed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ and scaling becomes ineffective after the error.
|
|||||||
too brittle for packaged atlas variants.
|
too brittle for packaged atlas variants.
|
||||||
10. Scale-path physical positioning used non-integer coordinates in `setPosition`, triggering
|
10. Scale-path physical positioning used non-integer coordinates in `setPosition`, triggering
|
||||||
runtime arg errors (`expected i32`) and bypassing window fit updates.
|
runtime arg errors (`expected i32`) and bypassing window fit updates.
|
||||||
|
11. Monitor-fit cap remained too optimistic for large frame packs, so max scale could still exceed
|
||||||
|
practical visible bounds and appear clipped.
|
||||||
|
12. Ferris/demogorgon packaged backgrounds are gradient-magenta (not exact `#FF00FF`), requiring a
|
||||||
|
border-connected magenta-family mask instead of exact-key assumptions.
|
||||||
|
|
||||||
## Fix Plan
|
## Fix Plan
|
||||||
|
|
||||||
@@ -132,6 +136,12 @@ Implemented:
|
|||||||
15. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
15. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||||
- switched ferris cleanup to hue/saturation/value magenta-band masking with connected background
|
- switched ferris cleanup to hue/saturation/value magenta-band masking with connected background
|
||||||
removal and stronger fallback cleanup.
|
removal and stronger fallback cleanup.
|
||||||
|
16. `frontend/tauri-ui/src/main.tsx`
|
||||||
|
- added stricter monitor work-area guard (`WINDOW_WORKAREA_MARGIN`) in both scale-cap and resize
|
||||||
|
clamp paths to prevent large-pack clipping at high scales.
|
||||||
|
17. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||||
|
- added deterministic border-connected strong-magenta flood-fill cleanup pass so non-`#FF00FF`
|
||||||
|
gradient backgrounds are removed consistently in packaged ferris/demogorgon atlases.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -169,6 +179,8 @@ Implemented:
|
|||||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to physical-unit resize math, adaptive key detection, and packaging UI refresh enforcement.
|
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to physical-unit resize math, adaptive key detection, and packaging UI refresh enforcement.
|
||||||
- `2026-02-14 00:00` - reporter - `In Progress` - reported default clipping, ferris background still present, and set_position float arg error.
|
- `2026-02-14 00:00` - reporter - `In Progress` - reported default clipping, ferris background still present, and set_position float arg error.
|
||||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - added integer-safe physical setPosition and HSV magenta cleanup strategy.
|
- `2026-02-14 00:00` - codex - `Fix Implemented` - added integer-safe physical setPosition and HSV magenta cleanup strategy.
|
||||||
|
- `2026-02-14 00:00` - reporter - `In Progress` - reported remaining default clipping and ferris magenta background persistence.
|
||||||
|
- `2026-02-14 00:00` - codex - `Fix Implemented` - tightened work-area scale guard and added border-connected strong-magenta cleanup pass.
|
||||||
|
|
||||||
## Closure
|
## Closure
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user