From ab0913c7a13d56a2dce9a888898af8adb9354b45 Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Mon, 14 Sep 2026 15:12:03 +0200 Subject: [PATCH 1/5] Add Active Save File search (#254) --- engine/Pksx.Pkhex.Engine/EngineContracts.cs | 11 + engine/Pksx.Pkhex.Engine/EngineJson.cs | 2 + .../Pksx.Pkhex.Engine/PkhexEngineExports.cs | 28 ++ src/lib/components/pksx/MainMenu.svelte | 4 +- src/lib/components/pksx/QuickSearch.svelte | 312 ++++++++++++++++++ src/lib/components/pksx/types.ts | 1 + src/lib/engine/index.ts | 2 + src/lib/engine/mock-engine.ts | 8 + .../pkhex-engine-worker-runtime.spec.ts | 6 + src/lib/engine/pkhex-engine-worker-runtime.ts | 17 + src/lib/engine/pkhex-engine.browser.spec.ts | 11 +- src/lib/engine/pkhex-engine.ts | 8 +- src/lib/engine/pkhex-worker-engine.ts | 14 +- src/lib/engine/types.ts | 7 + src/lib/engine/worker-protocol.ts | 33 ++ src/lib/pksx/box-shell/index.ts | 1 + src/lib/pksx/controller-input.spec.ts | 7 +- src/lib/pksx/controller-input.ts | 5 + src/lib/pksx/quick-search/host.svelte.ts | 44 +++ src/lib/pksx/quick-search/index.spec.ts | 125 +++++++ src/lib/pksx/quick-search/index.ts | 114 +++++++ src/lib/pksx/saves/types.ts | 1 + src/lib/pksx/summoned-workflow/index.ts | 1 + src/routes/+layout.svelte | 96 +++++- src/routes/box-shell.e2e.ts | 5 +- src/routes/boxes/+page.svelte | 147 ++++++++- src/routes/main-menu.e2e.ts | 2 +- src/routes/quick-search.e2e.ts | 308 +++++++++++++++++ src/routes/settings/+page.svelte | 3 +- 29 files changed, 1311 insertions(+), 12 deletions(-) create mode 100644 src/lib/components/pksx/QuickSearch.svelte create mode 100644 src/lib/pksx/quick-search/host.svelte.ts create mode 100644 src/lib/pksx/quick-search/index.spec.ts create mode 100644 src/lib/pksx/quick-search/index.ts create mode 100644 src/routes/quick-search.e2e.ts diff --git a/engine/Pksx.Pkhex.Engine/EngineContracts.cs b/engine/Pksx.Pkhex.Engine/EngineContracts.cs index ea7a62c6..a629ccec 100644 --- a/engine/Pksx.Pkhex.Engine/EngineContracts.cs +++ b/engine/Pksx.Pkhex.Engine/EngineContracts.cs @@ -23,6 +23,8 @@ public sealed record EngineError(string Code, string Message); public sealed record EngineVersion(string PkhexCoreVersion, string FacadeVersion); +public sealed record SpeciesNameProjection(ushort SpeciesId, string SpeciesName); + public sealed record SaveSummary( string? FileName, string SaveType, @@ -58,6 +60,7 @@ public static SaveSummary From(SaveFile save, string? fileName) => public sealed record PartySlotSummary( int Slot, ushort SpeciesId, + string SpeciesName, byte Form, byte Format, int Level, @@ -92,6 +95,7 @@ public static PartySlotSummary From(PKM pokemon, SaveFile save, int slot) => new( slot, pokemon.Species, + SlotDetailProjection.SpeciesName(pokemon), pokemon.Form, pokemon.Format, pokemon.Species == 0 ? 0 : pokemon.CurrentLevel, @@ -127,6 +131,7 @@ public sealed record BoxSlotSummary( int Box, int Slot, ushort SpeciesId, + string SpeciesName, byte Form, byte Format, int Level, @@ -162,6 +167,7 @@ public static BoxSlotSummary From(PKM pokemon, SaveFile save, int box, int slot) box, slot, pokemon.Species, + SlotDetailProjection.SpeciesName(pokemon), pokemon.Form, pokemon.Format, pokemon.Species == 0 ? 0 : pokemon.CurrentLevel, @@ -874,6 +880,11 @@ internal static class SlotDetailProjection 0.16, 0.15, 0.17, 0.16, 0.20, 0.07, 0.21, 0.05, 0.11 ]; + public static string SpeciesName(PKM pokemon) => SpeciesName(pokemon.Species); + + public static string SpeciesName(ushort speciesId) => + speciesId == 0 ? "" : NameAt(GameInfo.Strings.Species, speciesId) ?? $"Species {speciesId}"; + public static string? Gender(PKM pokemon) => pokemon.Species == 0 ? null : pokemon.Gender switch { diff --git a/engine/Pksx.Pkhex.Engine/EngineJson.cs b/engine/Pksx.Pkhex.Engine/EngineJson.cs index be9af3ad..8cd4ecd9 100644 --- a/engine/Pksx.Pkhex.Engine/EngineJson.cs +++ b/engine/Pksx.Pkhex.Engine/EngineJson.cs @@ -14,6 +14,7 @@ public static string Serialize(T value, JsonTypeInfo typeInfo) => [JsonSerializable(typeof(EngineResult))] [JsonSerializable(typeof(EngineResult))] [JsonSerializable(typeof(EngineResult))] +[JsonSerializable(typeof(EngineResult>))] [JsonSerializable(typeof(EngineResult))] [JsonSerializable(typeof(EngineResult))] [JsonSerializable(typeof(EngineResult))] @@ -43,4 +44,5 @@ public static string Serialize(T value, JsonTypeInfo typeInfo) => [JsonSerializable(typeof(SaveSlotRef))] [JsonSerializable(typeof(EngineResult>))] [JsonSerializable(typeof(EngineResult>))] +[JsonSerializable(typeof(List))] internal sealed partial class EngineJsonContext : JsonSerializerContext; diff --git a/engine/Pksx.Pkhex.Engine/PkhexEngineExports.cs b/engine/Pksx.Pkhex.Engine/PkhexEngineExports.cs index c9bbeffd..f346ab7a 100644 --- a/engine/Pksx.Pkhex.Engine/PkhexEngineExports.cs +++ b/engine/Pksx.Pkhex.Engine/PkhexEngineExports.cs @@ -91,6 +91,34 @@ public static string ProjectPreservationPayloadJson(byte[] payloadBytes, int tar } } + [JSExport] + public static string ProjectSpeciesNamesJson(string speciesIdsJson) + { + try + { + var speciesIds = System.Text.Json.JsonSerializer.Deserialize( + speciesIdsJson, + EngineJsonContext.Default.ListUInt16) ?? []; + var projections = speciesIds + .Distinct() + .Where(speciesId => speciesId > 0) + .Select(speciesId => new SpeciesNameProjection( + speciesId, + SlotDetailProjection.SpeciesName(speciesId))) + .ToList(); + + return EngineJson.Serialize( + EngineResult.Ok(projections), + EngineJsonContext.Default.EngineResultListSpeciesNameProjection); + } + catch (Exception ex) + { + return EngineJson.Serialize( + EngineResult.Fail("invalid-engine-response", ex.Message), + EngineJsonContext.Default.EngineResultObject); + } + } + [JSExport] public static string ParseSaveSmoke(byte[] bytes, string? fileName) { diff --git a/src/lib/components/pksx/MainMenu.svelte b/src/lib/components/pksx/MainMenu.svelte index ad270200..a10c7824 100644 --- a/src/lib/components/pksx/MainMenu.svelte +++ b/src/lib/components/pksx/MainMenu.svelte @@ -2,7 +2,7 @@ export const MAIN_MENU_SEARCH_INSERTION_INDEX = 1; export type MainMenuEntry = { - key: 'boxes' | 'trainer' | 'bag' | 'saves' | 'settings' | 'backup-browser'; + key: 'boxes' | 'search' | 'trainer' | 'bag' | 'saves' | 'settings' | 'backup-browser'; label: string; description: string; }; @@ -13,7 +13,7 @@ interface Props { entries: MainMenuEntry[]; - activeDestination: Exclude; + activeDestination: Exclude; activeIndex: number; onFocusEntry: (index: number) => void; onSelectEntry: (entry: MainMenuEntry) => void; diff --git a/src/lib/components/pksx/QuickSearch.svelte b/src/lib/components/pksx/QuickSearch.svelte new file mode 100644 index 00000000..c487fd35 --- /dev/null +++ b/src/lib/components/pksx/QuickSearch.svelte @@ -0,0 +1,312 @@ + + + + + + + + + diff --git a/src/lib/components/pksx/types.ts b/src/lib/components/pksx/types.ts index cfcfb9c6..eb9ba9ea 100644 --- a/src/lib/components/pksx/types.ts +++ b/src/lib/components/pksx/types.ts @@ -21,6 +21,7 @@ export type SlotView = { experience: number | null; experienceProjection: PokemonExperienceProjection | null; speciesId: number | null; + speciesName?: string; form: number | null; isEgg: boolean; spriteIdentity: SpriteIdentity | null; diff --git a/src/lib/engine/index.ts b/src/lib/engine/index.ts index e1b0bce4..00eea209 100644 --- a/src/lib/engine/index.ts +++ b/src/lib/engine/index.ts @@ -81,6 +81,7 @@ export type { SlotOperationResult, SerializedSave, SaveSummary, + SpeciesNameProjection, SpriteIdentity, StoredPokemonActionOperation, StoredPokemonActionResult, @@ -90,6 +91,7 @@ export type { export type { EngineWorkerFactory, EngineWorkerPort } from './pkhex-worker-engine'; export type { EngineWorkerGetVersionRequest, + EngineWorkerProjectSpeciesNamesRequest, EngineWorkerInitMessage, EngineWorkerApplySaveFileEditOperationRequest, EngineWorkerCreatePokemonRequest, diff --git a/src/lib/engine/mock-engine.ts b/src/lib/engine/mock-engine.ts index b305a5f6..80561e84 100644 --- a/src/lib/engine/mock-engine.ts +++ b/src/lib/engine/mock-engine.ts @@ -352,6 +352,14 @@ const mockPartySlots: PartySlotSummary[] = [ export function createMockEngine(overrides: Partial = {}): EngineApi { return { getVersion: async () => success(mockVersion), + projectSpeciesNames: async (speciesIds) => + success( + [...new Set(speciesIds)].map((speciesId) => ({ + speciesId, + speciesName: + speciesId === 1 ? 'Bulbasaur' : speciesId === 25 ? 'Pikachu' : `Species ${speciesId}` + })) + ), summarizeSave: async (_bytes, fileName) => success({ ...mockSaveSummary, fileName }), listBoxSlots: async (_bytes, _fileName, box) => { if (box !== 0) { diff --git a/src/lib/engine/pkhex-engine-worker-runtime.spec.ts b/src/lib/engine/pkhex-engine-worker-runtime.spec.ts index 0004f3a9..5d84ddd9 100644 --- a/src/lib/engine/pkhex-engine-worker-runtime.spec.ts +++ b/src/lib/engine/pkhex-engine-worker-runtime.spec.ts @@ -32,6 +32,12 @@ function createEngineExports(): DotnetPkhexEngineExports { value: { pkhexCoreVersion: '26.5.5.0', facadeVersion: '1.0.0.0' }, error: null }), + ProjectSpeciesNamesJson: () => + JSON.stringify({ + ok: true, + value: [{ speciesId: 1, speciesName: 'Bulbasaur' }], + error: null + }), ParseSaveSmoke: () => JSON.stringify({ ok: true, diff --git a/src/lib/engine/pkhex-engine-worker-runtime.ts b/src/lib/engine/pkhex-engine-worker-runtime.ts index 63fdbc78..f3ac37c9 100644 --- a/src/lib/engine/pkhex-engine-worker-runtime.ts +++ b/src/lib/engine/pkhex-engine-worker-runtime.ts @@ -17,6 +17,7 @@ import type { SaveFileInventoryCatalogue, SaveSummary, SaveWorkspace, + SpeciesNameProjection, SlotOperationResult, StoredPokemonImportResult, StoredPokemonActionResult, @@ -45,6 +46,7 @@ import { type EngineWorkerCreatePreservationPayloadRequest, type EngineWorkerReadPreservationPayloadRequest, type EngineWorkerProjectPreservationPayloadRequest, + type EngineWorkerProjectSpeciesNamesRequest, type EngineWorkerLoadSaveWorkspaceRequest, type EngineWorkerListBoxSlotsRequest, type EngineWorkerMessage, @@ -56,6 +58,7 @@ import { export type DotnetPkhexEngineExports = { GetVersionJson(): string; + ProjectSpeciesNamesJson(speciesIdsJson: string): string; ParseSaveSmoke(bytes: Uint8Array, fileName?: string): string; ListBoxSmoke(bytes: Uint8Array, fileName: string | undefined, box: number): string; LoadSaveWorkspaceJson(bytes: Uint8Array, fileName: string | undefined, box: number): string; @@ -242,6 +245,9 @@ export function createPkhexEngineWorkerRuntime({ ) ); return; + case 'projectSpeciesNames': + postMessage(createEngineWorkerResponse(request, projectSpeciesNames(engine, request))); + return; case 'summarizeSave': postMessage(createEngineWorkerResponse(request, summarizeSave(engine, request))); return; @@ -364,6 +370,15 @@ export function createPkhexEngineWorkerRuntime({ return { handleMessage }; } +function projectSpeciesNames( + engine: DotnetPkhexEngineExports, + request: EngineWorkerProjectSpeciesNamesRequest +): EngineResult { + return parseEngineResult( + engine.ProjectSpeciesNamesJson(JSON.stringify(request.payload.speciesIds)) + ); +} + function summarizeSave( engine: DotnetPkhexEngineExports, request: EngineWorkerSummarizeSaveRequest @@ -899,6 +914,8 @@ function unavailableResult(request: EngineWorkerRequest) { switch (request.method) { case 'getVersion': return result satisfies EngineResult; + case 'projectSpeciesNames': + return result satisfies EngineResult; case 'summarizeSave': return result satisfies EngineResult; case 'listBoxSlots': diff --git a/src/lib/engine/pkhex-engine.browser.spec.ts b/src/lib/engine/pkhex-engine.browser.spec.ts index be03879b..61617931 100644 --- a/src/lib/engine/pkhex-engine.browser.spec.ts +++ b/src/lib/engine/pkhex-engine.browser.spec.ts @@ -238,7 +238,7 @@ describe('PKHeX Engine browser runtime smoke', () => { } ); test('parses the Emerald Save File fixture through the published browser-wasm bundle', async () => { - expect.assertions(17); + expect.assertions(19); const [engine, fixtureResponse] = await Promise.all([ createPkhexEngine('/pkhex-engine'), @@ -252,6 +252,14 @@ describe('PKHeX Engine browser runtime smoke', () => { value: { pkhexCoreVersion: expect.stringMatching(/^26\.5\.5\./) } }); + const speciesNames = await engine.projectSpeciesNames([304, 1, 304]); + expect(speciesNames.ok).toBe(true); + if (!speciesNames.ok) throw new Error('Expected species names to project.'); + expect(speciesNames.value).toEqual([ + { speciesId: 304, speciesName: 'Aron' }, + { speciesId: 1, speciesName: 'Bulbasaur' } + ]); + const save = await engine.summarizeSave(fixtureBytes, '011020251345.sav'); expect(save).toStrictEqual({ ok: true, @@ -284,6 +292,7 @@ describe('PKHeX Engine browser runtime smoke', () => { box: 0, slot: 0, speciesId: 304, + speciesName: 'Aron', form: 0, format: 3, level: 11, diff --git a/src/lib/engine/pkhex-engine.ts b/src/lib/engine/pkhex-engine.ts index 738ef777..2ee70dfc 100644 --- a/src/lib/engine/pkhex-engine.ts +++ b/src/lib/engine/pkhex-engine.ts @@ -28,7 +28,8 @@ import type { StoredPokemonImportResult, StoredPokemonActionResult, SerializedSave, - SaveSummary + SaveSummary, + SpeciesNameProjection } from './types'; type RawDotnetModule = { @@ -51,6 +52,7 @@ type RawDotnetModule = { type DotnetPkhexEngineExports = { GetVersionJson(): string; + ProjectSpeciesNamesJson(speciesIdsJson: string): string; ParseSaveSmoke(bytes: Uint8Array, fileName?: string): string; ListBoxSmoke(bytes: Uint8Array, fileName: string | undefined, box: number): string; LoadSaveWorkspaceJson(bytes: Uint8Array, fileName: string | undefined, box: number): string; @@ -158,6 +160,10 @@ export async function createPkhexEngine(basePath = '/pkhex-engine'): Promise parseEngineResult(engine.GetVersionJson()), + projectSpeciesNames: async (speciesIds) => + parseEngineResult( + engine.ProjectSpeciesNamesJson(JSON.stringify(speciesIds)) + ), summarizeSave: async (bytes, fileName) => normalizeSaveSummaryResult( parseEngineResult(engine.ParseSaveSmoke(bytes, fileName)) diff --git a/src/lib/engine/pkhex-worker-engine.ts b/src/lib/engine/pkhex-worker-engine.ts index 4627cc91..5922df1a 100644 --- a/src/lib/engine/pkhex-worker-engine.ts +++ b/src/lib/engine/pkhex-worker-engine.ts @@ -20,7 +20,8 @@ import type { StoredPokemonImportResult, StoredPokemonActionResult, SerializedSave, - SaveSummary + SaveSummary, + SpeciesNameProjection } from './types'; import { parseEngineWorkerProtocolError, @@ -126,6 +127,13 @@ export function createPkhexWorkerEngine( return { getVersion: () => sendRequest('getVersion'), + projectSpeciesNames: (speciesIds) => + sendRequest('projectSpeciesNames', { + type: 'request', + id: createRequestId(), + method: 'projectSpeciesNames', + payload: { speciesIds } + }), summarizeSave: (bytes, fileName) => { const buffer = copyBytesToArrayBuffer(bytes); @@ -456,6 +464,10 @@ export function createPkhexWorkerEngine( }; async function sendRequest(method: 'getVersion'): Promise>; + async function sendRequest( + method: 'projectSpeciesNames', + request: Extract + ): Promise>; async function sendRequest( method: 'summarizeSave', request: Extract, diff --git a/src/lib/engine/types.ts b/src/lib/engine/types.ts index 00e8e287..31b66030 100644 --- a/src/lib/engine/types.ts +++ b/src/lib/engine/types.ts @@ -40,6 +40,11 @@ export type EngineVersion = { facadeVersion: string; }; +export type SpeciesNameProjection = { + speciesId: number; + speciesName: string; +}; + export type SaveSummary = { fileName?: string; saveType: string; @@ -80,6 +85,7 @@ export type BoxSlotSummary = { box: number; slot: number; speciesId: number; + speciesName?: string; form: number; format: number; level: number; @@ -662,6 +668,7 @@ export type PreservedPokemon = { export type EngineApi = { getVersion(): Promise>; + projectSpeciesNames(speciesIds: number[]): Promise>; summarizeSave(bytes: Uint8Array, fileName?: string): Promise>; listBoxSlots( bytes: Uint8Array, diff --git a/src/lib/engine/worker-protocol.ts b/src/lib/engine/worker-protocol.ts index 08d16089..74c89a3e 100644 --- a/src/lib/engine/worker-protocol.ts +++ b/src/lib/engine/worker-protocol.ts @@ -4,6 +4,7 @@ export const engineWorkerRequestIdSchema = z.string().min(1); export const engineWorkerMethodSchema = z.enum([ 'getVersion', + 'projectSpeciesNames', 'summarizeSave', 'listBoxSlots', 'loadSaveWorkspace', @@ -76,6 +77,11 @@ export const engineVersionSchema = z.object({ facadeVersion: z.string() }); +export const speciesNameProjectionSchema = z.object({ + speciesId: z.number().int(), + speciesName: z.string().min(1) +}); + export const saveSummarySchema = z.object({ fileName: z.string().optional(), saveType: z.string(), @@ -359,6 +365,7 @@ export const spriteIdentitySchema = z.object({ const slotSummaryFields = { slot: z.number(), speciesId: z.number(), + speciesName: z.string().optional(), form: z.number(), format: z.number(), level: z.number(), @@ -816,6 +823,10 @@ const engineResultSchema = (valueSchema: T) => export const engineVersionResultSchema = engineResultSchema(engineVersionSchema); +export const speciesNameProjectionListResultSchema = engineResultSchema( + z.array(speciesNameProjectionSchema) +); + export const saveSummaryResultSchema = engineResultSchema(saveSummarySchema); export const boxSlotSummaryListResultSchema = engineResultSchema(z.array(boxSlotSummarySchema)); @@ -875,6 +886,15 @@ export const engineWorkerGetVersionRequestSchema = z.object({ method: z.literal('getVersion') }); +export const engineWorkerProjectSpeciesNamesRequestSchema = z.object({ + type: z.literal('request'), + id: engineWorkerRequestIdSchema, + method: z.literal('projectSpeciesNames'), + payload: z.object({ + speciesIds: z.array(z.number().int().min(1).max(65_535)) + }) +}); + export const engineWorkerSummarizeSaveRequestSchema = z.object({ type: z.literal('request'), id: engineWorkerRequestIdSchema, @@ -1114,6 +1134,7 @@ export const engineWorkerProjectPreservationPayloadRequestSchema = z.object({ export const engineWorkerRequestSchema = z.discriminatedUnion('method', [ engineWorkerGetVersionRequestSchema, + engineWorkerProjectSpeciesNamesRequestSchema, engineWorkerSummarizeSaveRequestSchema, engineWorkerListBoxSlotsRequestSchema, engineWorkerLoadSaveWorkspaceRequestSchema, @@ -1145,6 +1166,13 @@ export const engineWorkerGetVersionResponseSchema = z.object({ result: engineVersionResultSchema }); +export const engineWorkerProjectSpeciesNamesResponseSchema = z.object({ + type: z.literal('response'), + id: engineWorkerRequestIdSchema, + method: z.literal('projectSpeciesNames'), + result: speciesNameProjectionListResultSchema +}); + export const engineWorkerSummarizeSaveResponseSchema = z.object({ type: z.literal('response'), id: engineWorkerRequestIdSchema, @@ -1301,6 +1329,7 @@ export const engineWorkerProjectPreservationPayloadResponseSchema = z.object({ export const engineWorkerResponseSchema = z.discriminatedUnion('method', [ engineWorkerGetVersionResponseSchema, + engineWorkerProjectSpeciesNamesResponseSchema, engineWorkerSummarizeSaveResponseSchema, engineWorkerListBoxSlotsResponseSchema, engineWorkerLoadSaveWorkspaceResponseSchema, @@ -1361,6 +1390,10 @@ export type EngineWorkerInitMessage = z.infer; +export type EngineWorkerProjectSpeciesNamesRequest = z.infer< + typeof engineWorkerProjectSpeciesNamesRequestSchema +>; + export type EngineWorkerSummarizeSaveRequest = z.infer< typeof engineWorkerSummarizeSaveRequestSchema >; diff --git a/src/lib/pksx/box-shell/index.ts b/src/lib/pksx/box-shell/index.ts index 315370c6..9b61f5f1 100644 --- a/src/lib/pksx/box-shell/index.ts +++ b/src/lib/pksx/box-shell/index.ts @@ -126,6 +126,7 @@ export function createSlotView(slot: PartySlotSummary | BoxSlotSummary): SlotVie return { slot: slot.slot, label: slot.nickname || `Species ${slot.speciesId}`, + speciesName: slot.speciesName, detail: `Lv. ${slot.level}`, level: slot.level, experience: slot.experience, diff --git a/src/lib/pksx/controller-input.spec.ts b/src/lib/pksx/controller-input.spec.ts index 0ece8627..eab2c4c5 100644 --- a/src/lib/pksx/controller-input.spec.ts +++ b/src/lib/pksx/controller-input.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { readGamepadKeys } from './controller-input'; +import { controllerShortcutAction, readGamepadKeys } from './controller-input'; describe('readGamepadKeys', () => { test('maps standard buttons and axes to keyboard navigation', () => { @@ -39,6 +39,11 @@ describe('readGamepadKeys', () => { }); }); +test('maps controller Y to the reserved Search action', () => { + expect(controllerShortcutAction('y')).toBe('search'); + expect(controllerShortcutAction('Y')).toBe('search'); +}); + function createGamepad({ axes, pressedButtons = [], diff --git a/src/lib/pksx/controller-input.ts b/src/lib/pksx/controller-input.ts index 5f5964ca..fb1a48cc 100644 --- a/src/lib/pksx/controller-input.ts +++ b/src/lib/pksx/controller-input.ts @@ -24,6 +24,10 @@ export function dispatchControllerKey(key: ControllerKey) { if (window.dispatchEvent(event)) handleControllerFallback(key); } +export function controllerShortcutAction(key: string): NavigationAction | null { + return key.toLowerCase() === 'y' ? 'search' : null; +} + export function controllerFocusSystem() { const handleKeydown = (event: KeyboardEvent) => { if (!isControllerKeyboardEvent(event)) { @@ -246,3 +250,4 @@ function readHatAxis(value: number): ControllerKey[] { function pressed(gamepad: Gamepad, index: number) { return gamepad.buttons[index]?.pressed === true; } +import type { NavigationAction } from '$lib/pksx/box-navigation'; diff --git a/src/lib/pksx/quick-search/host.svelte.ts b/src/lib/pksx/quick-search/host.svelte.ts new file mode 100644 index 00000000..05b70e20 --- /dev/null +++ b/src/lib/pksx/quick-search/host.svelte.ts @@ -0,0 +1,44 @@ +import { getContext, setContext } from 'svelte'; +import type { QuickSearchResult } from '.'; + +const quickSearchHostKey = Symbol('pksx-quick-search-host'); + +export type QuickSearchCollection = { + label: string; + isAvailable(): Promise; + loadResults(): Promise; + focusResult(result: QuickSearchResult): Promise; +}; + +export type QuickSearchProvider = { + captureFocusedCollection(): QuickSearchCollection | null; +}; + +export type QuickSearchHost = { + register(provider: QuickSearchProvider): () => void; + getProvider(): QuickSearchProvider | null; +}; + +export function createQuickSearchHost(): QuickSearchHost { + let current: QuickSearchProvider | null = null; + + return { + register(provider) { + current = provider; + return () => { + if (current === provider) current = null; + }; + }, + getProvider() { + return current; + } + }; +} + +export function setQuickSearchHost(host: QuickSearchHost) { + return setContext(quickSearchHostKey, host); +} + +export function getQuickSearchHost() { + return getContext(quickSearchHostKey); +} diff --git a/src/lib/pksx/quick-search/index.spec.ts b/src/lib/pksx/quick-search/index.spec.ts new file mode 100644 index 00000000..8ccbe476 --- /dev/null +++ b/src/lib/pksx/quick-search/index.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import type { BoxSlotSummary, PartySlotSummary } from '$lib/engine'; +import { createEmptyPokemonStorage, type StoredPokemonStoragePokemon } from '$lib/pksx/saves'; +import { + createPokemonStorageQuickSearchResults, + createSaveFileQuickSearchResults, + filterQuickSearchResults +} from '.'; + +const slotProjection = { + form: 0, + format: 3, + level: 5, + experience: 125, + experienceProjection: null, + isEgg: false, + isEmpty: false, + gender: null, + nature: null, + ability: null, + heldItem: null, + heldItemSpriteIdentity: null, + types: [], + stats: [], + moves: [], + natureEditConstraints: {} as BoxSlotSummary['natureEditConstraints'], + heldItemEditConstraints: {} as BoxSlotSummary['heldItemEditConstraints'], + abilityEditConstraints: {} as BoxSlotSummary['abilityEditConstraints'], + metDataEditConstraints: {} as BoxSlotSummary['metDataEditConstraints'], + originalTrainerEditConstraints: {} as BoxSlotSummary['originalTrainerEditConstraints'], + statEditConstraints: {} as BoxSlotSummary['statEditConstraints'], + moveSetEditConstraints: {} as BoxSlotSummary['moveSetEditConstraints'], + friendshipEditConstraints: {} as BoxSlotSummary['friendshipEditConstraints'], + battleFields: [], + spriteIdentity: { + speciesId: 25, + form: 0, + isEgg: false, + isShiny: false, + displaySex: 'default' as const + } +}; + +describe('Quick Search', () => { + it('matches Save File species, nicknames, and visible Location labels', () => { + const party = [ + { + ...slotProjection, + slot: 0, + speciesId: 25, + speciesName: 'Pikachu', + nickname: 'Sparky' + } + ] satisfies PartySlotSummary[]; + const boxes = [ + { + ...slotProjection, + box: 1, + slot: 4, + speciesId: 133, + speciesName: 'Eevee', + nickname: 'Eevee' + } + ] satisfies BoxSlotSummary[]; + const results = createSaveFileQuickSearchResults({ + collectionKey: 'save-1', + collectionLabel: 'emerald.sav', + paneId: 'pane-save', + partySlots: party, + boxSlots: boxes + }); + + expect(filterQuickSearchResults(results, 'pikachu')).toMatchObject([ + { collectionLabel: 'emerald.sav', locationLabel: 'Party, Slot 1', slot: 0 } + ]); + expect(filterQuickSearchResults(results, 'sparky')).toHaveLength(1); + expect(filterQuickSearchResults(results, 'box 02')).toMatchObject([ + { speciesName: 'Eevee', locationLabel: 'Box 02, Slot 5', box: 1, slot: 4 } + ]); + expect(filterQuickSearchResults(results, ' ')).toEqual([]); + }); + + it('uses Pokemon Storage Box names and keeps exact result identity', () => { + const storage = createEmptyPokemonStorage(); + storage.boxes[2]!.name = 'Favorites'; + storage.boxes[2]!.slots[7]!.pokemon = { + label: 'Buddy', + detail: 'Lv. 12', + level: 12, + experience: null, + speciesId: 1, + form: 0, + isEgg: false, + spriteIdentity: null, + origin: { + entryMode: 'imported', + originSaveFileName: null, + originGame: null, + originalTrainer: null, + trainerId: null, + enteredAt: '2026-09-14T00:00:00.000Z' + } + } satisfies StoredPokemonStoragePokemon; + + const results = createPokemonStorageQuickSearchResults({ + paneId: 'pane-storage', + storage, + speciesNames: new Map([[1, 'Bulbasaur']]) + }); + + expect(filterQuickSearchResults(results, 'favorites')).toEqual([ + expect.objectContaining({ + id: 'pokemon-storage:box:2:7', + collectionLabel: 'Pokemon Storage', + locationLabel: 'Favorites, Slot 8', + speciesName: 'Bulbasaur', + nickname: 'Buddy', + paneId: 'pane-storage', + zone: 'box', + box: 2, + slot: 7 + }) + ]); + }); +}); diff --git a/src/lib/pksx/quick-search/index.ts b/src/lib/pksx/quick-search/index.ts new file mode 100644 index 00000000..dcc49e4e --- /dev/null +++ b/src/lib/pksx/quick-search/index.ts @@ -0,0 +1,114 @@ +import type { BoxSlotSummary, PartySlotSummary } from '$lib/engine'; +import type { StoredPokemonStorage } from '$lib/pksx/saves'; + +export type QuickSearchResult = { + id: string; + collectionKey: string; + collectionLabel: string; + paneId: string; + speciesName: string; + nickname: string; + locationLabel: string; + zone: 'party' | 'box'; + box: number | null; + slot: number; +}; + +type SaveFileSearchInput = { + collectionKey: string; + collectionLabel: string; + paneId: string; + partySlots: PartySlotSummary[]; + boxSlots: BoxSlotSummary[]; +}; + +export function createSaveFileQuickSearchResults(input: SaveFileSearchInput): QuickSearchResult[] { + return [ + ...input.partySlots + .filter((slot) => !slot.isEmpty) + .map((slot) => createResult(input, slot, 'party', null, `Party, Slot ${slot.slot + 1}`)), + ...input.boxSlots + .filter((slot) => !slot.isEmpty) + .map((slot) => + createResult( + input, + slot, + 'box', + slot.box, + `Box ${String(slot.box + 1).padStart(2, '0')}, Slot ${slot.slot + 1}` + ) + ) + ]; +} + +export function createPokemonStorageQuickSearchResults(input: { + paneId: string; + storage: StoredPokemonStorage; + speciesNames?: ReadonlyMap; +}): QuickSearchResult[] { + return input.storage.boxes.flatMap((box) => + box.slots.flatMap((storageSlot) => { + const pokemon = storageSlot.pokemon; + if (!pokemon) return []; + return [ + { + id: `pokemon-storage:box:${box.index}:${storageSlot.slot}`, + collectionKey: 'pokemon-storage', + collectionLabel: 'Pokemon Storage', + paneId: input.paneId, + speciesName: + pokemon.speciesName ?? + (pokemon.speciesId === null ? undefined : input.speciesNames?.get(pokemon.speciesId)) ?? + pokemon.label, + nickname: pokemon.label, + locationLabel: `${box.name}, Slot ${storageSlot.slot + 1}`, + zone: 'box' as const, + box: box.index, + slot: storageSlot.slot + } + ]; + }) + ); +} + +export function filterQuickSearchResults( + results: QuickSearchResult[], + query: string +): QuickSearchResult[] { + const normalizedQuery = normalize(query.trim()); + if (!normalizedQuery) return []; + + return results.filter((result) => + [result.speciesName, result.nickname, result.locationLabel].some((value) => + normalize(value).includes(normalizedQuery) + ) + ); +} + +function createResult( + input: Pick, + slot: PartySlotSummary | BoxSlotSummary, + zone: QuickSearchResult['zone'], + box: number | null, + locationLabel: string +): QuickSearchResult { + return { + id: `${input.collectionKey}:${zone}:${box ?? 'party'}:${slot.slot}`, + collectionKey: input.collectionKey, + collectionLabel: input.collectionLabel, + paneId: input.paneId, + speciesName: slot.speciesName ?? slot.nickname, + nickname: slot.nickname, + locationLabel, + zone, + box, + slot: slot.slot + }; +} + +function normalize(value: string) { + return value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLocaleLowerCase(); +} diff --git a/src/lib/pksx/saves/types.ts b/src/lib/pksx/saves/types.ts index cc53e616..b615169e 100644 --- a/src/lib/pksx/saves/types.ts +++ b/src/lib/pksx/saves/types.ts @@ -62,6 +62,7 @@ export type StoredPokemonStorageSlot = { export type StoredPokemonStoragePokemon = { label: string; + speciesName?: string; detail: string; level: number | null; experience: number | null; diff --git a/src/lib/pksx/summoned-workflow/index.ts b/src/lib/pksx/summoned-workflow/index.ts index ec3558d2..683415c1 100644 --- a/src/lib/pksx/summoned-workflow/index.ts +++ b/src/lib/pksx/summoned-workflow/index.ts @@ -9,6 +9,7 @@ export type SummonedWorkflowKind = | 'slot-menu' | 'box-menu' | 'main-menu' + | 'quick-search' | 'save-file-menu' | 'save-file-delete' | 'source-picker' diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 893fc685..f6ad62ad 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -14,6 +14,7 @@ MAIN_MENU_SEARCH_INSERTION_INDEX, type MainMenuEntry } from '$lib/components/pksx/MainMenu.svelte'; + import QuickSearch from '$lib/components/pksx/QuickSearch.svelte'; import ToastRegion from '$lib/components/pksx/ToastRegion.svelte'; import { appChrome } from '$lib/pksx/app-chrome.svelte'; import { heightBandLock } from '$lib/pksx/height-band-lock'; @@ -27,6 +28,12 @@ } from '$lib/pksx/destination-focus'; import { setDestinationFocusIdentityGetter } from '$lib/pksx/destination-focus-context.svelte'; import { getSavesStorage } from '$lib/pksx/saves-cache'; + import type { QuickSearchResult } from '$lib/pksx/quick-search'; + import { + createQuickSearchHost, + setQuickSearchHost, + type QuickSearchCollection + } from '$lib/pksx/quick-search/host.svelte'; import { setRouteBackRegistrar } from '$lib/pksx/route-back-context.svelte'; import { theme } from '$lib/pksx/theme.svelte'; import { createToastHost, setToastHost } from '$lib/pksx/toast/host.svelte'; @@ -35,6 +42,7 @@ setSummonedWorkflowHost } from '$lib/pksx/summoned-workflow/host.svelte'; import { + controllerShortcutAction, controllerFocusSystem, dispatchControllerKey, isControllerKeyboardEvent, @@ -44,6 +52,7 @@ let { children } = $props(); const summonedWorkflow = setSummonedWorkflowHost(createSummonedWorkflowHost()); + const quickSearchHost = setQuickSearchHost(createQuickSearchHost()); const toastHost = setToastHost(createToastHost()); const storage = getSavesStorage(); const destinationFocus = new SvelteMap(); @@ -64,11 +73,13 @@ let platformHistoryDepth = 0; let replaceNextRouteHistory = false; let hasActiveSaveFile = $state(false); + let quickSearchCollection = $state(null); let activeSaveAvailabilityRequest = 0; let routeTransitionRequest = 0; const destinationOrder: Destination[] = ['saves', 'boxes', 'trainer', 'bag', 'settings']; const activeRoute = $derived(destinationForPathname(page.url.pathname)); const mainMenuOpen = $derived(summonedWorkflow.active?.kind === 'main-menu'); + const quickSearchOpen = $derived(summonedWorkflow.active?.kind === 'quick-search'); const mainMenuEntries = $derived.by(() => { const entriesAfterReservedSearch: MainMenuEntry[] = [ { @@ -99,7 +110,12 @@ const entries: MainMenuEntry[] = [ { key: 'boxes', label: 'Boxes', description: 'Browse the active collections.' } ]; - entries.splice(MAIN_MENU_SEARCH_INSERTION_INDEX, 0, ...entriesAfterReservedSearch); + entries.splice(MAIN_MENU_SEARCH_INSERTION_INDEX, 0, { + key: 'search', + label: 'Search', + description: 'Find a Pokemon in the focused collection.' + }); + entries.splice(MAIN_MENU_SEARCH_INSERTION_INDEX + 1, 0, ...entriesAfterReservedSearch); return entries; }); @@ -210,6 +226,12 @@ async function selectMainMenuEntry(entry: MainMenuEntry) { if (!mainMenuOpen) return; + if (entry.key === 'search') { + const launcher = summonedWorkflow.active?.launcher; + summonedWorkflow.closeAll(); + await openQuickSearch(launcher?.type === 'control' ? launcher : undefined); + return; + } if (entry.key === activeRoute) { closeMainMenu(); return; @@ -227,6 +249,63 @@ await restoreDestinationFocus(entry.key); } + async function openQuickSearch(launcher?: { type: 'control'; id: string }) { + if (summonedWorkflow.active || appChrome.carryActive) return; + let searchLauncher = launcher; + + if (activeRoute !== 'boxes') { + skipNextFocusCapture = true; + await goto(resolve('/boxes'), { keepFocus: true }); + await restoreDestinationFocus('boxes'); + searchLauncher = undefined; + } + + const collection = quickSearchHost.getProvider()?.captureFocusedCollection() ?? null; + if (!collection) { + await restoreDestinationFocus('boxes'); + return; + } + + const launcherId = + searchLauncher?.id ?? rememberDestinationFocus() ?? ensureDestinationFocus('boxes'); + if (!launcherId) return; + quickSearchCollection = collection; + if (!summonedWorkflow.open('quick-search', { type: 'control', id: launcherId })) { + quickSearchCollection = null; + } + } + + function closeQuickSearch() { + if (!quickSearchOpen) return; + const launcher = summonedWorkflow.dismiss(); + quickSearchCollection = null; + queueMicrotask(() => { + const target = launcher ? document.getElementById(launcher.id) : null; + const route = destinationRoute(activeRoute); + if (isFocusableTarget(target) && route?.contains(target)) target.focus(); + else void restoreDestinationFocus(activeRoute); + }); + } + + async function selectQuickSearchResult(result: QuickSearchResult) { + const collection = quickSearchCollection; + if (!quickSearchOpen || !collection) return false; + const focusId = await collection.focusResult(result); + if (!focusId) return false; + + summonedWorkflow.dismiss(); + quickSearchCollection = null; + await tick(); + const target = document.getElementById(focusId); + if (isFocusableTarget(target)) { + target.focus(); + target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } else { + await restoreDestinationFocus('boxes'); + } + return true; + } + function destinationPath(destination: Destination) { switch (destination) { case 'boxes': @@ -252,6 +331,7 @@ function handleRootKeydown(event: KeyboardEvent) { const fromController = isControllerKeyboardEvent(event); + const controllerAction = fromController ? controllerShortcutAction(event.key) : null; const shortcut = !fromController && (event.metaKey || event.ctrlKey) && @@ -264,6 +344,12 @@ return; } + if (controllerAction === 'search' && !summonedWorkflow.active && !appChrome.carryActive) { + consumeRootEvent(event); + void openQuickSearch(); + return; + } + if (fromController && event.key === 'Menu') { consumeRootEvent(event); if (mainMenuOpen) closeMainMenu(); @@ -645,7 +731,7 @@ 'app-shell', 'pksx-density', theme.dark && 'dark', - summonedWorkflow.active?.kind === 'backup-browser' && 'takeover-active' + (summonedWorkflow.active?.kind === 'backup-browser' || quickSearchOpen) && 'takeover-active' ]} aria-label="PKSX" onfocusin={handleShellFocusIn} @@ -683,6 +769,12 @@ /> {:else if summonedWorkflow.active?.kind === 'backup-browser'} + {:else if quickSearchOpen && quickSearchCollection} + {/if} diff --git a/src/routes/box-shell.e2e.ts b/src/routes/box-shell.e2e.ts index faba67dc..9362e974 100644 --- a/src/routes/box-shell.e2e.ts +++ b/src/routes/box-shell.e2e.ts @@ -3642,7 +3642,10 @@ test('controller input follows the keyboard navigation path', async ({ page }) = await expect(page.locator('#box-0-slot-1')).toBeFocused(); await pressController(page, 'y'); - await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByRole('dialog', { name: 'Quick Search' })).toBeVisible(); + await pressController(page, 'Escape'); + await expect(page.getByRole('dialog', { name: 'Quick Search' })).toBeHidden(); + await expect(page.locator('#box-0-slot-1')).toBeFocused(); await pressController(page, 'x'); await expect(page.getByRole('dialog', { name: 'Box Menu' })).toBeVisible(); await expect(page.locator('.boxes-route')).toHaveAttribute('inert', ''); diff --git a/src/routes/boxes/+page.svelte b/src/routes/boxes/+page.svelte index 8f17ec47..b5307aee 100644 --- a/src/routes/boxes/+page.svelte +++ b/src/routes/boxes/+page.svelte @@ -174,6 +174,15 @@ } from '$lib/pksx/summoned-workflow'; import { getSummonedWorkflowHost } from '$lib/pksx/summoned-workflow/host.svelte'; import { getToastHost } from '$lib/pksx/toast/host.svelte'; + import { + createPokemonStorageQuickSearchResults, + createSaveFileQuickSearchResults, + type QuickSearchResult + } from '$lib/pksx/quick-search'; + import { + getQuickSearchHost, + type QuickSearchCollection + } from '$lib/pksx/quick-search/host.svelte'; import { createBoxMenuCommands, type BoxMenuCommandKey } from '$lib/pksx/box-menu'; import { createSlotMenuCommands, type SlotMenuCommandKey } from '$lib/pksx/slot-menu'; import { layerFade, panelSettle } from '$lib/pksx/motion'; @@ -237,6 +246,7 @@ const storage = getSavesStorage(); const workspaceService = getActiveWorkspaceService(); const summonedWorkflow = getSummonedWorkflowHost(); + const quickSearchHost = getQuickSearchHost(); const toastHost = getToastHost(); const slotPalette = [16, 28, 48, 100, 140, 180, 195, 210, 220, 260, 280, 295, 330, 52]; @@ -1349,7 +1359,11 @@ } function handleAppKeydown(event: KeyboardEvent) { - if (activeSummonedWorkflow?.kind === 'backup-browser') return; + if ( + activeSummonedWorkflow?.kind === 'backup-browser' || + activeSummonedWorkflow?.kind === 'quick-search' + ) + return; const action = keyboardAction(event); if (!action) { @@ -2840,6 +2854,7 @@ return { slot, label: pokemon.label, + speciesName: pokemon.speciesName, detail: pokemon.detail, level: pokemon.level, experience: pokemon.experience, @@ -2882,6 +2897,7 @@ ): StoredPokemonStoragePokemon { return { label: slot.label, + speciesName: slot.speciesName, detail: slot.detail, level: slot.level, experience: slot.experience, @@ -4446,7 +4462,135 @@ ); } + function captureFocusedQuickSearchCollection(): QuickSearchCollection | null { + const pane = activePane; + if (!initialStateReady || !pane) return null; + const source = { ...pane.source }; + + return { + label: source.label, + isAvailable: async () => { + if (!matchesQuickSearchCollection(pane.id, source)) return false; + return source.type === 'pokemon-storage' + ? (await storage.getPokemonStorage()) !== null + : Boolean(source.id && (await storage.getSave(source.id))); + }, + loadResults: () => loadQuickSearchResults(pane.id, source), + focusResult: (result) => focusQuickSearchResult(result, pane.id, source) + }; + } + + function matchesQuickSearchCollection(paneId: string, source: BoxSourceRef) { + return workbenchPanes.some( + (pane) => + pane.id === paneId && pane.source.type === source.type && pane.source.id === source.id + ); + } + + async function loadQuickSearchResults(paneId: string, source: BoxSourceRef) { + if (!matchesQuickSearchCollection(paneId, source)) return []; + if (source.type === 'pokemon-storage') { + const stored = await storage.getPokemonStorage(); + if (!stored) return []; + const speciesIds = [ + ...new Set( + stored.boxes.flatMap((box) => + box.slots.flatMap(({ pokemon }) => + pokemon && !pokemon.speciesName && pokemon.speciesId ? [pokemon.speciesId] : [] + ) + ) + ) + ]; + const projection = + speciesIds.length > 0 && engine ? await engine.projectSpeciesNames(speciesIds) : null; + if (projection && !projection.ok) throw projection.error; + if (!matchesQuickSearchCollection(paneId, source)) return []; + return createPokemonStorageQuickSearchResults({ + paneId, + storage: stored, + speciesNames: new Map( + projection?.ok + ? projection.value.map(({ speciesId, speciesName }) => [speciesId, speciesName]) + : [] + ) + }); + } + if (!source.id || !engine) return []; + + const [saveFile, saveBytes, persistedWorkspace] = await Promise.all([ + storage.getSave(source.id), + storage.getSaveBytes(source.id), + storage.getWorkspace(source.id) + ]); + if (!saveFile || !saveBytes || !matchesQuickSearchCollection(paneId, source)) return []; + const bytes = persistedWorkspace?.bytes ?? saveBytes; + const workspace = await loadWorkspace(bytes, saveFile.originalFileName ?? undefined, 0); + const boxSlots = [...workspace.boxSlots]; + for (let box = 1; box < workspace.summary.boxCount; box += 1) { + const result = await engine.listBoxSlots(bytes, saveFile.originalFileName ?? undefined, box); + if (!result.ok) throw result.error; + boxSlots.push(...result.value); + } + if (!matchesQuickSearchCollection(paneId, source)) return []; + + return createSaveFileQuickSearchResults({ + collectionKey: source.id, + collectionLabel: source.label, + paneId, + partySlots: workspace.partySlots, + boxSlots + }); + } + + async function focusQuickSearchResult( + result: QuickSearchResult, + paneId: string, + source: BoxSourceRef + ) { + if ( + result.paneId !== paneId || + result.collectionKey !== source.id || + !matchesQuickSearchCollection(paneId, source) + ) { + return null; + } + + if (source.type === 'pokemon-storage') { + const stored = await storage.getPokemonStorage(); + if (!stored) return null; + pokemonStorage = stored; + } else if (!source.id || !(await storage.getSave(source.id))) { + return null; + } + + const pane = workbenchPanes.find((candidate) => candidate.id === paneId); + if (!pane) return null; + const focus = result.zone === 'party' ? focusPartySlot(result.slot) : focusBoxSlot(result.slot); + const activeBox = result.box ?? pane.activeBox; + activePaneId = paneId; + workbenchPanes = setPaneFocus( + setPaneActiveBox(workbenchPanes, paneId, activeBox), + paneId, + focus + ); + navigation = { + ...navigation, + activeBox, + boxCount: Math.max(1, pane.boxCount), + focus, + locationFocus: focus + }; + + if (source.type === 'save-file' && result.zone === 'box') { + await refreshPaneWorkspace(paneId, activeBox); + } + return getFocusId(focus, activeBox); + } + onMount(() => { + const unregisterQuickSearch = quickSearchHost.register({ + captureFocusedCollection: captureFocusedQuickSearchCollection + }); const unsubscribe = workspaceService.subscribe((state) => { const adoptAsActiveSave = state ? consumeActiveSaveAdoption(state.file.id) : false; loadedSave = state; @@ -4468,6 +4612,7 @@ void restoreInitialState(); return () => { resizeObserver?.disconnect(); + unregisterQuickSearch(); unsubscribe(); }; }); diff --git a/src/routes/main-menu.e2e.ts b/src/routes/main-menu.e2e.ts index 0aae6647..3d775ffe 100644 --- a/src/routes/main-menu.e2e.ts +++ b/src/routes/main-menu.e2e.ts @@ -121,13 +121,13 @@ test('empty first run lands on Saves and exposes the amended selectable destinat let menu = await openMainMenu(page); await expect(menu.locator('.main-menu-row strong')).toHaveText([ 'Boxes', + 'Search', 'Trainer', 'Bag', 'Saves', 'Settings', 'Backup Browser' ]); - await expect(menu.getByRole('button', { name: /^Search/ })).toHaveCount(0); await expect(menu.getByRole('button', { name: /^Saves/ })).toHaveAttribute( 'aria-current', 'page' diff --git a/src/routes/quick-search.e2e.ts b/src/routes/quick-search.e2e.ts new file mode 100644 index 00000000..a72c34ad --- /dev/null +++ b/src/routes/quick-search.e2e.ts @@ -0,0 +1,308 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import path from 'node:path'; + +const emeraldFixturePath = path.resolve( + 'test-fixtures/save-files/bl1ndbeholder-pokemon-saves/emerald-011020251345.sav' +); + +async function resetStorage(page: Page) { + await page.goto('/'); + await page.evaluate( + () => + new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase('pksx-saves'); + request.onerror = () => reject(request.error ?? new Error('Could not clear Saves.')); + request.onsuccess = () => resolve(); + }) + ); + await page.reload(); + await expect(page.locator('[data-destination-root="saves"]')).toHaveAttribute( + 'data-initial-state', + 'ready' + ); +} + +async function seedPokemonStorage(page: Page) { + await page.evaluate( + () => + new Promise((resolve, reject) => { + const request = indexedDB.open('pksx-saves', 4); + request.onerror = () => reject(request.error ?? new Error('Could not open Saves.')); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction('pokemonStorage', 'readwrite'); + transaction.objectStore('pokemonStorage').put({ + id: 'pokemon-storage', + schemaVersion: 1, + boxCount: 3, + boxSlotCount: 30, + boxes: Array.from({ length: 3 }, (_, box) => ({ + index: box, + name: box === 2 ? 'Favorites' : `Box ${String(box + 1).padStart(2, '0')}`, + slots: Array.from({ length: 30 }, (_, slot) => ({ + box, + slot, + pokemon: + box === 2 && slot === 7 + ? { + label: 'Buddy', + detail: 'Lv. 12', + level: 12, + experience: null, + speciesId: 1, + form: 0, + isEgg: false, + spriteIdentity: null, + origin: { + entryMode: 'imported', + originSaveFileName: null, + originGame: null, + originalTrainer: null, + trainerId: null, + enteredAt: '2026-09-14T00:00:00.000Z' + } + } + : null + })) + })), + updatedAt: '2026-09-14T00:00:00.000Z' + }); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + }; + }) + ); +} + +async function openSearchFromMainMenu(page: Page) { + await page.getByRole('button', { name: 'Open Main Menu' }).click(); + const menu = page.getByRole('dialog', { name: 'Main Menu' }); + await expect(menu.locator('.main-menu-row strong')).toHaveText([ + 'Boxes', + 'Search', + 'Trainer', + 'Bag', + 'Saves', + 'Settings', + 'Backup Browser' + ]); + await menu.getByRole('button', { name: /^Search/ }).click(); + return page.getByRole('dialog', { name: 'Quick Search' }); +} + +async function pressController(page: Page, key: string) { + for (const pressed of [true, false]) { + await page.evaluate( + ({ controllerKey, isPressed }) => + window.dispatchEvent( + new CustomEvent('pksxcontroller', { + detail: { + key: controllerKey, + pressed: isPressed, + discrete: true, + id: 'Acceptance controller' + } + }) + ), + { controllerKey: key, isPressed: pressed } + ); + } +} + +async function expectQuickSearchViewport(page: Page, width: number, height: number) { + await page.setViewportSize({ width, height }); + const search = page.getByRole('dialog', { name: 'Quick Search' }); + const input = search.getByRole('searchbox'); + await expect(input).toBeFocused(); + await expect(input).toHaveCSS('font-size', '16px'); + + const errors = await page.evaluate(() => { + const shell = document.querySelector('.app-shell'); + const dialog = document.querySelector( + '[role="dialog"][aria-labelledby="quick-search-title"]' + ); + const searchInput = dialog?.querySelector('input[type="search"]'); + if (!shell || !dialog || !searchInput) return ['Quick Search geometry is unavailable.']; + const rect = (element: Element) => element.getBoundingClientRect(); + const dialogRect = rect(dialog); + const inputRect = rect(searchInput); + const style = getComputedStyle(shell); + const safe = { + left: parseFloat(style.getPropertyValue('--pksx-safe-area-left')), + top: parseFloat(style.getPropertyValue('--pksx-safe-area-top')), + right: innerWidth - parseFloat(style.getPropertyValue('--pksx-safe-area-right')), + bottom: innerHeight - parseFloat(style.getPropertyValue('--pksx-safe-area-bottom')) + }; + const within = (target: DOMRect, boundary: typeof safe) => + target.left >= boundary.left - 1 && + target.top >= boundary.top - 1 && + target.right <= boundary.right + 1 && + target.bottom <= boundary.bottom + 1; + const failures: string[] = []; + if (!within(dialogRect, safe)) failures.push('Quick Search escapes the Safe Canvas.'); + if (!within(inputRect, dialogRect)) failures.push('Search input escapes the Takeover.'); + if (document.documentElement.scrollWidth > innerWidth + 1) + failures.push('Document width exceeds the viewport.'); + if (document.documentElement.scrollHeight > innerHeight + 1) + failures.push('Document height exceeds the viewport.'); + if (document.body.scrollWidth > innerWidth + 1 || document.body.scrollHeight > innerHeight + 1) + failures.push('Body extent exceeds the viewport.'); + if (shell.scrollWidth > shell.clientWidth + 1 || shell.scrollHeight > shell.clientHeight + 1) + failures.push('Shell extent exceeds its bounds.'); + return failures; + }); + + expect(errors).toEqual([]); +} + +test('searches Pokemon Storage and preserves the workflow through viewport changes', async ({ + page +}) => { + await resetStorage(page); + await seedPokemonStorage(page); + await page.setViewportSize({ width: 640, height: 360 }); + await page.goto('/boxes?source=pokemon-storage'); + await expect(page.locator('[data-destination-root="boxes"]')).toHaveAttribute( + 'data-initial-state', + 'ready' + ); + await page.locator('#box-0-slot-3').focus(); + + const search = await openSearchFromMainMenu(page); + const input = search.getByRole('searchbox', { name: 'Search Pokemon Storage' }); + await expect(input).toBeFocused(); + await expect(input).toHaveCSS('font-size', '16px'); + await expect(search).toContainText('Type a species, nickname, or Location.'); + await input.fill('bulbasaur'); + const result = search.getByRole('button', { + name: /Buddy.*Bulbasaur.*Pokemon Storage.*Favorites, Slot 8/ + }); + await expect(result).toBeVisible(); + + for (const viewport of [ + { width: 640, height: 360 }, + { width: 640, height: 480 }, + { width: 360, height: 640 }, + { width: 393, height: 852 } + ]) { + await expectQuickSearchViewport(page, viewport.width, viewport.height); + await expect(input).toHaveValue('bulbasaur'); + await expect(result).toBeVisible(); + } + await page.setViewportSize({ width: 640, height: 559 }); + await expect(input).toBeFocused(); + await page.setViewportSize({ width: 640, height: 560 }); + await expect(input).toBeFocused(); + + await result.click(); + await expect(search).toBeHidden(); + await expect(page).toHaveURL(/\/boxes/); + await expect(page.locator('[data-pane-id="pane-pokemon-storage"]')).toHaveAttribute( + 'data-location', + 'box-2' + ); + await expect(page.locator('#box-2-slot-7')).toBeFocused(); +}); + +test('controller Search opens Quick Search without consuming typed input', async ({ page }) => { + await resetStorage(page); + await seedPokemonStorage(page); + await page.goto('/boxes?source=pokemon-storage'); + await expect(page.locator('[data-destination-root="boxes"]')).toHaveAttribute( + 'data-initial-state', + 'ready' + ); + + await page.locator('#box-0-slot-4').focus(); + await pressController(page, 'y'); + const search = page.getByRole('dialog', { name: 'Quick Search' }); + const input = search.getByRole('searchbox', { name: 'Search Pokemon Storage' }); + await expect(input).toBeFocused(); + await page.keyboard.type('missing'); + await expect(search).toContainText('No Pokemon match "missing".'); + await input.fill(''); + await page.keyboard.type('buddy'); + await expect(input).toHaveValue('buddy'); + await expect(search.getByRole('button', { name: /Buddy.*Favorites, Slot 8/ })).toBeVisible(); + await pressController(page, 'ArrowDown'); + await expect(search.getByRole('button', { name: /Buddy.*Favorites, Slot 8/ })).toBeFocused(); + await pressController(page, 'Enter'); + await expect(page.locator('#box-2-slot-7')).toBeFocused(); + + await pressController(page, 'y'); + await expect(page.getByRole('dialog', { name: 'Quick Search' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('#box-2-slot-7')).toBeFocused(); +}); + +test('Main Menu Search opens the focused collection from another destination', async ({ page }) => { + await resetStorage(page); + await seedPokemonStorage(page); + await page.goto('/settings'); + const search = await openSearchFromMainMenu(page); + await expect(page).toHaveURL(/\/boxes/); + await expect(search.getByRole('searchbox', { name: 'Search Pokemon Storage' })).toBeFocused(); +}); + +test('shows safe empty and disappeared collection states', async ({ page }) => { + await resetStorage(page); + await page.goto('/boxes?source=pokemon-storage'); + await expect(page.locator('[data-destination-root="boxes"]')).toHaveAttribute( + 'data-initial-state', + 'ready' + ); + + const search = await openSearchFromMainMenu(page); + await expect(search).toContainText('Pokemon Storage has no Pokemon to search.'); + await search.getByRole('button', { name: 'Close Quick Search' }).click(); + await expect(page.locator('#box-0-slot-0')).toBeFocused(); + + await seedPokemonStorage(page); + await page.reload(); + await expect(page.locator('[data-destination-root="boxes"]')).toHaveAttribute( + 'data-initial-state', + 'ready' + ); + const reopened = await openSearchFromMainMenu(page); + await page.evaluate( + () => + new Promise((resolve, reject) => { + const request = indexedDB.open('pksx-saves', 4); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction('pokemonStorage', 'readwrite'); + transaction.objectStore('pokemonStorage').delete('pokemon-storage'); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + }; + }) + ); + await reopened.getByRole('searchbox').fill('buddy'); + await expect(reopened).toContainText('Pokemon Storage is no longer available.'); + await page.keyboard.press('Escape'); + await expect(page.locator('#box-0-slot-0')).toBeFocused(); +}); + +test('searches a Save File projection and opens the exact result Slot', async ({ page }) => { + await resetStorage(page); + await page.getByLabel('Import Save File').setInputFiles(emeraldFixturePath); + await expect(page.getByText(/imported and made active/)).toBeVisible({ timeout: 15_000 }); + await page.goto('/boxes'); + await expect(page.locator('#box-0-slot-0')).toContainText('ARON', { timeout: 15_000 }); + + const search = await openSearchFromMainMenu(page); + const input = search.getByRole('searchbox', { name: /Search emerald-011020251345\.sav/ }); + await input.fill('Aron'); + const result = search.getByRole('button', { name: /ARON.*Aron.*Box 01, Slot 1/ }); + await expect(result).toBeVisible({ timeout: 15_000 }); + await result.click(); + await expect(search).toBeHidden(); + await expect(page.locator('#box-0-slot-0')).toBeFocused(); +}); diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 90d60146..f10a4119 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -24,7 +24,8 @@ { action: 'Move focus', controller: 'D-pad', keyboard: 'Arrow keys' }, { action: 'Confirm', controller: 'A', keyboard: 'Enter' }, { action: 'Back', controller: 'B', keyboard: 'Escape' }, - { action: 'Main Menu', controller: 'Start', keyboard: 'Cmd/Ctrl+K' } + { action: 'Main Menu', controller: 'Start', keyboard: 'Cmd/Ctrl+K' }, + { action: 'Quick Search', controller: 'Y', keyboard: 'Main Menu' } ] }, { From 68df452312ec9daf572e4887517ebcdc2da1cc8f Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Mon, 14 Sep 2026 16:21:15 +0200 Subject: [PATCH 2/5] Update Android menu navigation acceptance --- .../java/com/pksx/app/ControllerNavigationTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/app/src/androidTest/java/com/pksx/app/ControllerNavigationTest.java b/android/app/src/androidTest/java/com/pksx/app/ControllerNavigationTest.java index 7a211925..e9a407c5 100644 --- a/android/app/src/androidTest/java/com/pksx/app/ControllerNavigationTest.java +++ b/android/app/src/androidTest/java/com/pksx/app/ControllerNavigationTest.java @@ -468,6 +468,10 @@ public void controllerHighlightSurvivesRepeatedMainMenuNavigation() throws Excep KeyEvent.KEYCODE_DPAD_DOWN, controllerHighlightExpression("main-menu-entry-1") ); + pressGamepadKey( + KeyEvent.KEYCODE_DPAD_DOWN, + controllerHighlightExpression("main-menu-entry-2") + ); pressGamepadKey( KeyEvent.KEYCODE_BUTTON_A, "location.pathname.endsWith('/trainer')" From 7a57848092bc16715f24c402ab7013a2aad4020e Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Mon, 14 Sep 2026 17:16:13 +0200 Subject: [PATCH 3/5] Refine Active Save File search --- engine/Pksx.Pkhex.Engine/EngineContracts.cs | 2 - engine/Pksx.Pkhex.Engine/EngineJson.cs | 2 - .../Pksx.Pkhex.Engine/PkhexEngineExports.cs | 28 -- src/lib/components/pksx/QuickSearch.svelte | 264 ++++++++++-------- src/lib/components/pksx/TakeoverFrame.svelte | 38 ++- src/lib/engine/index.ts | 2 - src/lib/engine/mock-engine.ts | 8 - .../pkhex-engine-worker-runtime.spec.ts | 6 - src/lib/engine/pkhex-engine-worker-runtime.ts | 17 -- src/lib/engine/pkhex-engine.browser.spec.ts | 10 +- src/lib/engine/pkhex-engine.ts | 8 +- src/lib/engine/pkhex-worker-engine.ts | 14 +- src/lib/engine/types.ts | 6 - src/lib/engine/worker-protocol.ts | 32 --- src/lib/pksx/controller-input.ts | 3 +- src/lib/pksx/quick-search/host.svelte.ts | 6 +- src/lib/pksx/quick-search/index.spec.ts | 58 +--- src/lib/pksx/quick-search/index.ts | 47 +--- src/routes/+layout.svelte | 43 +-- src/routes/box-shell.e2e.ts | 9 +- src/routes/boxes/+page.svelte | 82 ++---- src/routes/main-menu.e2e.ts | 12 +- src/routes/quick-search.e2e.ts | 226 ++++----------- src/routes/settings.e2e.ts | 4 +- src/routes/settings/+page.svelte | 4 +- 25 files changed, 324 insertions(+), 607 deletions(-) diff --git a/engine/Pksx.Pkhex.Engine/EngineContracts.cs b/engine/Pksx.Pkhex.Engine/EngineContracts.cs index a629ccec..0e82e929 100644 --- a/engine/Pksx.Pkhex.Engine/EngineContracts.cs +++ b/engine/Pksx.Pkhex.Engine/EngineContracts.cs @@ -23,8 +23,6 @@ public sealed record EngineError(string Code, string Message); public sealed record EngineVersion(string PkhexCoreVersion, string FacadeVersion); -public sealed record SpeciesNameProjection(ushort SpeciesId, string SpeciesName); - public sealed record SaveSummary( string? FileName, string SaveType, diff --git a/engine/Pksx.Pkhex.Engine/EngineJson.cs b/engine/Pksx.Pkhex.Engine/EngineJson.cs index 8cd4ecd9..be9af3ad 100644 --- a/engine/Pksx.Pkhex.Engine/EngineJson.cs +++ b/engine/Pksx.Pkhex.Engine/EngineJson.cs @@ -14,7 +14,6 @@ public static string Serialize(T value, JsonTypeInfo typeInfo) => [JsonSerializable(typeof(EngineResult))] [JsonSerializable(typeof(EngineResult))] [JsonSerializable(typeof(EngineResult))] -[JsonSerializable(typeof(EngineResult>))] [JsonSerializable(typeof(EngineResult))] [JsonSerializable(typeof(EngineResult))] [JsonSerializable(typeof(EngineResult))] @@ -44,5 +43,4 @@ public static string Serialize(T value, JsonTypeInfo typeInfo) => [JsonSerializable(typeof(SaveSlotRef))] [JsonSerializable(typeof(EngineResult>))] [JsonSerializable(typeof(EngineResult>))] -[JsonSerializable(typeof(List))] internal sealed partial class EngineJsonContext : JsonSerializerContext; diff --git a/engine/Pksx.Pkhex.Engine/PkhexEngineExports.cs b/engine/Pksx.Pkhex.Engine/PkhexEngineExports.cs index f346ab7a..c9bbeffd 100644 --- a/engine/Pksx.Pkhex.Engine/PkhexEngineExports.cs +++ b/engine/Pksx.Pkhex.Engine/PkhexEngineExports.cs @@ -91,34 +91,6 @@ public static string ProjectPreservationPayloadJson(byte[] payloadBytes, int tar } } - [JSExport] - public static string ProjectSpeciesNamesJson(string speciesIdsJson) - { - try - { - var speciesIds = System.Text.Json.JsonSerializer.Deserialize( - speciesIdsJson, - EngineJsonContext.Default.ListUInt16) ?? []; - var projections = speciesIds - .Distinct() - .Where(speciesId => speciesId > 0) - .Select(speciesId => new SpeciesNameProjection( - speciesId, - SlotDetailProjection.SpeciesName(speciesId))) - .ToList(); - - return EngineJson.Serialize( - EngineResult.Ok(projections), - EngineJsonContext.Default.EngineResultListSpeciesNameProjection); - } - catch (Exception ex) - { - return EngineJson.Serialize( - EngineResult.Fail("invalid-engine-response", ex.Message), - EngineJsonContext.Default.EngineResultObject); - } - } - [JSExport] public static string ParseSaveSmoke(byte[] bytes, string? fileName) { diff --git a/src/lib/components/pksx/QuickSearch.svelte b/src/lib/components/pksx/QuickSearch.svelte index c487fd35..a02e8ec3 100644 --- a/src/lib/components/pksx/QuickSearch.svelte +++ b/src/lib/components/pksx/QuickSearch.svelte @@ -1,26 +1,27 @@