From 7ea4e02538b4590e2218dd2b73a931c74c79bcde Mon Sep 17 00:00:00 2001 From: Crauzer <0xcrauzer@proton.me> Date: Fri, 3 Jul 2026 15:47:12 +0200 Subject: [PATCH] feat(workshop, overlay): implement string overrides system --- Cargo.lock | 56 ++- docs/plans/string-overrides-implementation.md | 231 +++++++++++ package.json | 1 + pnpm-lock.yaml | 22 ++ src-tauri/Cargo.toml | 13 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/strings.rs | 37 ++ src-tauri/src/commands/workshop.rs | 3 +- src-tauri/src/main.rs | 2 + src-tauri/src/overlay/mod.rs | 25 ++ src-tauri/src/setup.rs | 1 + src-tauri/src/state.rs | 8 + src-tauri/src/strings/mod.rs | 333 ++++++++++++++++ src-tauri/src/utils/locale.rs | 188 +++++++++ src-tauri/src/utils/mod.rs | 1 + src-tauri/src/workshop/layers.rs | 20 +- src-tauri/src/workshop/mod.rs | 4 +- src-tauri/src/workshop/packing.rs | 4 +- src/components/Combobox.tsx | 2 +- src/components/DataTable.tsx | 165 ++++++++ src/components/ExternalLink.tsx | 32 ++ src/components/FormField.tsx | 2 +- src/components/Select.tsx | 2 +- src/components/Table.tsx | 112 ++++++ src/components/Tabs.tsx | 6 +- src/components/index.ts | 3 + src/hooks/index.ts | 1 + src/hooks/useDebouncedValue.ts | 15 + src/lib/bindings/Settings.ts | 6 + src/lib/bindings/StringKeySearchResult.ts | 17 + src/lib/bindings/StringKeySuggestion.ts | 16 + src/lib/bindings/index.ts | 2 + src/lib/tauri.ts | 3 + .../settings/components/AboutSection.tsx | 23 +- .../settings/components/PatchingSection.tsx | 19 + src/modules/workshop/api/index.ts | 1 + .../workshop/api/useStringKeySearch.ts | 28 ++ src/modules/workshop/index.ts | 1 + .../components/LayerOverrideBadge.tsx | 16 + .../components/StringKeyField.tsx | 91 +++++ .../components/StringOverridesEmptyState.tsx | 27 ++ .../components/StringOverridesHelpPopover.tsx | 44 +++ .../components/StringOverridesTable.tsx | 185 +++++++++ .../components/StringOverridesToolbar.tsx | 43 ++ .../string-overrides/components/index.ts | 6 + .../workshop/string-overrides/constants.ts | 30 ++ .../workshop/string-overrides/hooks/index.ts | 1 + .../hooks/useStringOverridesEditor.ts | 209 ++++++++++ .../workshop/string-overrides/index.ts | 3 + .../workshop/string-overrides/types.ts | 7 + src/routes/workshop/$projectName/strings.tsx | 368 ++++-------------- src/test/fixtures.ts | 1 + 52 files changed, 2099 insertions(+), 339 deletions(-) create mode 100644 docs/plans/string-overrides-implementation.md create mode 100644 src-tauri/src/commands/strings.rs create mode 100644 src-tauri/src/strings/mod.rs create mode 100644 src-tauri/src/utils/locale.rs create mode 100644 src/components/DataTable.tsx create mode 100644 src/components/ExternalLink.tsx create mode 100644 src/components/Table.tsx create mode 100644 src/hooks/useDebouncedValue.ts create mode 100644 src/lib/bindings/StringKeySearchResult.ts create mode 100644 src/lib/bindings/StringKeySuggestion.ts create mode 100644 src/modules/workshop/api/useStringKeySearch.ts create mode 100644 src/modules/workshop/string-overrides/components/LayerOverrideBadge.tsx create mode 100644 src/modules/workshop/string-overrides/components/StringKeyField.tsx create mode 100644 src/modules/workshop/string-overrides/components/StringOverridesEmptyState.tsx create mode 100644 src/modules/workshop/string-overrides/components/StringOverridesHelpPopover.tsx create mode 100644 src/modules/workshop/string-overrides/components/StringOverridesTable.tsx create mode 100644 src/modules/workshop/string-overrides/components/StringOverridesToolbar.tsx create mode 100644 src/modules/workshop/string-overrides/components/index.ts create mode 100644 src/modules/workshop/string-overrides/constants.ts create mode 100644 src/modules/workshop/string-overrides/hooks/index.ts create mode 100644 src/modules/workshop/string-overrides/hooks/useStringOverridesEditor.ts create mode 100644 src/modules/workshop/string-overrides/index.ts create mode 100644 src/modules/workshop/string-overrides/types.ts diff --git a/Cargo.lock b/Cargo.lock index 2224a9dd..46d16b19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2744,6 +2744,7 @@ dependencies = [ "filetime", "flate2", "image", + "indexmap 2.14.0", "libloading 0.9.0", "ltk_fantome", "ltk_file", @@ -2751,6 +2752,7 @@ dependencies = [ "ltk_mod_project", "ltk_modpkg", "ltk_overlay", + "ltk_rst", "ltk_wad", "notify", "notify-debouncer-mini", @@ -2759,6 +2761,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml_ng", "slug", "tar", "tauri", @@ -2793,13 +2796,14 @@ dependencies = [ [[package]] name = "ltk_fantome" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b510d628512c4e3abbcd06cd759b3f1a08f8ee202c4a020a05b7dc1dd096d7a" +checksum = "a6a6bb75085cc9274f6d80959bf52347bd579bbc919ee829c1b946d2c7040995" dependencies = [ "camino", "eyre", "image", + "indexmap 2.14.0", "itertools", "ltk_mod_project", "ltk_wad", @@ -2860,10 +2864,11 @@ dependencies = [ [[package]] name = "ltk_mod_project" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b19b9af50be7da6534709046f6973c62275f37d284329ff0de6bb718eadf9e" +checksum = "4ce4512cbd546953ed2bc55e80df8fd2def40fee9c59c803acbfedb318e7109d" dependencies = [ + "indexmap 2.14.0", "serde", "serde_json", "thiserror 2.0.18", @@ -2872,15 +2877,16 @@ dependencies = [ [[package]] name = "ltk_modpkg" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bdff18687f42db90e61344a8a098c59983025d5464c63851e2f150f08fe2d5" +checksum = "780dfeaf47dd7d9ebf3c73a0d1a25b6fcee552cf194c0e7504c5c05601a3c86d" dependencies = [ "binrw", "byteorder", "camino", "glob", "image", + "indexmap 2.14.0", "itertools", "ltk_io_ext 0.2.1", "ltk_mod_project", @@ -2898,16 +2904,18 @@ dependencies = [ [[package]] name = "ltk_overlay" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "569035c8426863ad67180c8d83621422e757313311360f1d0c13e6d94ad22902" +checksum = "3cce42f1500bb965793388b33ada86b8ece171d240642a704227a54a97e93e7d" dependencies = [ "byteorder", "camino", + "indexmap 2.14.0", "ltk_fantome", "ltk_file", "ltk_mod_project", "ltk_modpkg", + "ltk_rst", "ltk_wad", "memmap2", "rayon", @@ -2945,6 +2953,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ltk_rst" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c47f87ae3e31064792f6f7a8b8d56da0ef9a83d92b419cce97f4d37a078a8110" +dependencies = [ + "byteorder", + "num_enum", + "thiserror 1.0.69", + "xxhash-rust", +] + [[package]] name = "ltk_wad" version = "0.2.15" @@ -4778,6 +4798,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -6171,6 +6204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ "chrono", + "indexmap 2.14.0", "serde_json", "thiserror 2.0.18", "ts-rs-macros", @@ -6270,6 +6304,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/docs/plans/string-overrides-implementation.md b/docs/plans/string-overrides-implementation.md new file mode 100644 index 00000000..235ae5d4 --- /dev/null +++ b/docs/plans/string-overrides-implementation.md @@ -0,0 +1,231 @@ +# String Overrides — Implementation Plan + +> Status: **implemented on feature branches** (2026-07-02): league-mod `feat/string-override-application` (pushed; Phase 2 complete incl. tests) and ltk-manager `feat/apply-string-overrides` (Phase 3 complete). Remaining: manual E2E (§7 step 3) and the release chain (§7 step 4, gated on PR #138 review). +> Closes: ltk-manager [#123](https://github.com/LeagueToolkit/ltk-manager/issues/123), league-mod [#97](https://github.com/LeagueToolkit/league-mod/issues/97). +> Depends on: league-toolkit [PR #138](https://github.com/LeagueToolkit/league-toolkit/pull/138) (`ltk_rst` API rework) — consumed as a **git dependency on the PR branch** while review is pending (head `d033293` as of 2026-07-02), so development is not blocked on the merge/publish. + +## 1. Current state (verified 2026-07-02) + +**Already done — do not redo:** + +| Piece | Where | Status | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| Editor UI (per-layer, per-locale key/value editor) | `src/routes/workshop/$projectName/strings.tsx` | Done; shows "not applied yet" warning (lines 176–195) | +| Save command + hook | `commands/workshop.rs:166` `save_layer_string_overrides`, `useSaveStringOverrides.ts` | Done | +| Project persistence | `ltk_mod_project 0.4.1` — `ModProjectLayer.string_overrides: HashMap>` in `mod.config.json` | Done | +| Ship in `.modpkg` | `ltk_modpkg 0.5.0` — `ModpkgLayerMetadata.string_overrides` (msgpack metadata, schema v2); `ProjectPacker::build_layer_metadata` copies them on pack | Done | +| Ship in `.fantome` | `ltk_fantome 0.5.1` — `FantomeLayerInfo.string_overrides` (`META/info.json` → `"StringOverrides"`), writer emits them | Done | +| Workshop import round-trip | `workshop/projects.rs` — both fantome (lines ~304–327) and modpkg (~418–420) importers preserve overrides | Done | +| Overlay stage plumbing | `ltk_overlay::OverlayStage::ApplyingStringOverrides` exists (never emitted); ltk-manager maps it to local `OverlayStage::Strings` (`overlay/mod.rs:107`); frontend maps it in `useOverlayProgress.ts` | Plumbed, dormant | + +**Missing — the actual work:** + +1. No RST reader/writer in the dependency tree (`ltk_rst` not referenced anywhere). +2. `ltk_overlay 0.3.1` never applies overrides: `collect`/`patch` pipeline ignores `ModProject.layers[].string_overrides` entirely. +3. **Bug:** `ltk_overlay::FantomeContent::mod_project()` (`fantome_content.rs:189–222`) builds `default_layers()` and _discards_ fantome `StringOverrides` — fantome mods lose overrides at build time even though the archive carries them. +4. No locale detection (nothing reads `LeagueClientSettings.yaml`; no YAML crate in `Cargo.lock`). +5. No settings toggle for "apply to all locales". + +**Key facts for implementation:** + +- Game locale: `C:\Riot Games\League of Legends\Config\LeagueClientSettings.yaml` → `install.globals.locale` (e.g. `"en_US"`). The Config dir is a sibling of `Game/` under the League root. +- Target file: chunk `data/menu/{locale_lower}/lol.stringtable` inside `Game/DATA/FINAL/Localized/Global.{locale}.wad.client` (one WAD per installed locale; verified `Global.en_US.wad.client` on this machine). +- `ltk_rst` (post-#138 API): `Stringtable::from_reader` (defaults = latest format: V5, 38-bit hash/offset split, XXH3 — correct for the live game), `insert_str(key, value)` (lowercases + hashes + masks the key), `insert(hash, value)` (masks a full 64-bit hash), `to_writer` (deterministic, sorted, deduped output). No manual hashing needed. +- CDragon key-name list (for optional UI autocomplete): `https://github.com/CommunityDragon/Data/blob/master/hashes/lol/hashes.rst.xxh3.txt` — ~7 MB, lines of `<16-hex full xxh3> `. +- Locale keys in the data model are lowercase (`en_us`, `ko_kr`, …) plus `"default"`; the YAML value is `en_US` — normalize to lowercase when matching, but match WAD filenames case-insensitively. + +## 2. Design decisions + +**D1 — Application lives in `ltk_overlay`, not ltk-manager.** The overlay builder already owns game-WAD indexing, chunk conflict resolution, parallel patching, and incremental state. ltk-manager only supplies configuration (which locales to patch). This also satisfies league-mod #97 so the CLI benefits too. + +**D2 — Two-track dependency strategy** (never local path deps): + +- _Development (now):_ consume `ltk_rst` straight from the PR branch as a git dependency in `ltk_overlay`; consume the in-progress `ltk_overlay` in ltk-manager the same way (git dep on the league-mod feature branch). Cargo.lock pins the exact revs; update deliberately with `cargo update -p ltk_rst` / `-p ltk_overlay` when the branches move. +- _Release:_ crates.io rejects git dependencies, so publishing still happens in order — merge PR #138 → publish `ltk_rst 0.2.0` → swap `ltk_overlay`'s git dep for the version → publish `ltk_overlay 0.4.0` → swap ltk-manager's git dep for the version. Git deps are a feature-branch-only state; nothing merges to a `main` with a git dep in it. + +The YAML dep (`serde_yaml_ng`) goes only into ltk-manager. + +**D3 — Merge/priority semantics** (must mirror chunk-conflict ordering so string conflicts and file conflicts resolve identically): + +- Iterate enabled mods in the same order the builder uses for chunk overrides; later/higher-priority wins ("last write wins"). +- Within a mod, iterate enabled layers ascending by layer priority. +- Within a layer, apply the `"default"` locale bucket first, then the locale-specific bucket — so locale-specific beats `"default"` _within the same layer_, but a higher-priority mod's `"default"` still beats a lower-priority mod's locale-specific entry (mod priority dominates). + +**D4 — Base-table selection.** If an enabled mod ships a literal `lol.stringtable` chunk override for the target WAD, parse the _winning_ mod-provided chunk as the base instead of the game's copy, then apply key-level overrides on top. Otherwise extract + decompress the game's chunk. + +**D5 — Locale targeting.** `ltk_overlay` gets a mode enum; ltk-manager computes it from settings + YAML: + +- Toggle OFF (default): patch only the detected current locale. +- Toggle ON: patch every installed locale (every `Global.*.wad.client` in `Localized/`). +- Only build a localized WAD when its effective override map is non-empty (or when mods contribute regular chunk overrides to it, as today). + +**D6 — Raw-hash key escape hatch (recommended, small).** Accept keys of the form `{hex}` (1–16 hex digits, e.g. `{f772a83b33773223}`) as pre-computed hashes via `Stringtable::insert`, for entries whose plaintext key is unknown. Document in the strings editor. + +## 3. Phase 1 — league-toolkit: consume the PR ref now, publish later + +Repo: `LeagueToolkit/league-toolkit`, branch `rst-api-rework` (PR #138, open, awaiting review — head `d033293b1bf5e7108615bf00830d49da238c64b5` as of 2026-07-02). + +**Interim (unblocks Phase 2 immediately):** depend on the PR branch directly — + +```toml +ltk_rst = { git = "https://github.com/LeagueToolkit/league-toolkit.git", branch = "rst-api-rework" } +``` + +Cargo.lock pins the exact rev; if review pushes new commits (or force-pushes), refresh deliberately with `cargo update -p ltk_rst` and re-run the string tests. Watch the PR for API changes requested in review — anything touching `Stringtable`/`RstFormat` surface ripples into `ltk_overlay/src/strings.rs`. + +**Release gate (parallel, not blocking development):** + +1. Get PR #138 reviewed and merged (assignee: Crauzer; reviewers DexalGT / alanpq). +2. Bump `crates/ltk_rst/Cargo.toml` version `0.1.0` → `0.2.0` (the PR is a breaking rework of the already-published 0.1.0; the in-branch manifest still says 0.1.0). +3. Publish `ltk_rst 0.2.0` to crates.io, then swap the git dep for the version (see D2). + +No code work needed here beyond the release — the API already covers everything this feature needs (read, keyed insert, hash insert, deterministic write, format handling incl. V2 font-config preservation and 40/39/38-bit splits). + +## 4. Phase 2 — league-mod: string application engine in `ltk_overlay` + +Repo: `X:\dev\league-mod` (sync with origin first — local shows `ltk_overlay 0.3.0`, crates.io is at 0.3.1). Target version: **`ltk_overlay 0.4.0`**. + +### 4.1 New module `crates/ltk_overlay/src/strings.rs` + +```rust +/// Which locales string overrides are applied to. +pub enum StringOverrideMode { + /// Skip string patching entirely. + Disabled, + /// Patch only these locales (normalized lowercase, e.g. "en_us"). + Locales(Vec), + /// Patch every locale found in Game/DATA/FINAL/Localized. + AllInstalled, +} + +/// locale -> field key -> value, merged across mods/layers per D3. +pub(crate) fn collect_effective_overrides( + mods: &mut [EnabledMod], + target_locales: &[String], +) -> Result>>; + +/// Parse `original` with ltk_rst, apply `overrides` (incl. `{hex}` raw-hash +/// keys per D6), serialize back. Preserves the table's read format. +pub(crate) fn patch_stringtable( + original: &[u8], + overrides: &HashMap, +) -> Result>; +``` + +- `Cargo.toml`: add `ltk_rst` as the git dep from Phase 1 (swap to `ltk_rst = "0.2"` before publishing `ltk_overlay` — crates.io rejects git deps). +- `collect_effective_overrides` reads `content.mod_project()` (already exposed by `ModContentProvider`) for each enabled mod, filters to `enabled_layers`, and merges per D3. For each target locale L, effective map = fold over (mod, layer) of `default` bucket then `L` bucket. +- Installed-locale discovery: scan the game dir for `Localized/Global.*.wad.client` (case-insensitive), extract the locale token from the filename. + +### 4.2 Builder integration (`crates/ltk_overlay/src/builder/mod.rs`) + +- `OverlayBuilder::with_string_overrides(mode: StringOverrideMode)` (default `Disabled` — non-breaking for existing callers). +- In `build()`, after `collect_all_override_metadata`/`resolve_overrides_for_wads` and before `patch_wads_parallel`: + 1. Emit `OverlayStage::ApplyingStringOverrides` (the reserved variant — first real emission; ltk-manager already maps it). + 2. Resolve target locales from the mode; compute effective override maps. + 3. For each locale with a non-empty map: + - Resolve the game WAD `Global.{locale}.wad.client` via `GameIndex` (verify the index includes the `Localized/` subtree; it should — it lives under `DATA/FINAL`). Missing WAD → `tracing::warn!` and skip. + - Compute the chunk hash for `data/menu/{locale}/lol.stringtable` (existing path-hash helpers, xxh64-lowercase — do _not_ reuse modpkg's xxh3 helpers). + - Base bytes per D4 (winning mod chunk override if present, else mount game WAD and decompress the chunk). + - `patch_stringtable(...)` → register the result as a synthetic override for that WAD/chunk so `partition_wads_from_meta` marks the WAD for building and `build_patched_wad` compresses it like any other override. + 4. Ensure a WAD whose _only_ contribution is string overrides still enters the build partition (today a mod with no file content contributes nothing, so the localized WAD would never be scheduled). + +### 4.3 Incremental-rebuild correctness + +- Fold into the localized WAD's fingerprint: a stable hash of its effective override map + the mode/locale list. Toggling the all-locales setting, changing the client locale, or editing any override must rebuild exactly the affected localized WADs (and removing all overrides must drop the WAD from the overlay). +- Bump the overlay state schema version → one full rebuild on upgrade (ltk-manager already has force-rebuild-on-version-change from `e7ebe7b`). + +### 4.4 Fix `FantomeContent::mod_project()` (`crates/ltk_overlay/src/fantome_content.rs:189–222`) + +Parse `META/info.json` layers and map `FantomeLayerInfo.string_overrides` into the returned `ModProjectLayer`s instead of `default_layers()`. Without this, fantome mods never contribute overrides. + +### 4.5 Tests (in `ltk_overlay`) + +- `patch_stringtable` unit tests against small fixture tables (reuse/borrow the `ltk_rst` test fixtures): keyed override, raw-hash `{hex}` override, untouched entries preserved, deterministic output. +- Merge-semantics tests for D3 (mod priority beats locale specificity; layer priority; `default` vs locale bucket). +- Integration test: fixture game dir with a tiny `Localized/Global.en_us.wad.client` containing a tiny stringtable + one modpkg and one fantome mod with overrides → build → mount output WAD → assert patched values; second build with no changes → WAD reused; toggle mode → rebuild. + +### 4.6 Release + +Changelog, publish `ltk_overlay 0.4.0`. (No `ltk_modpkg`/`ltk_fantome`/`ltk_mod_project` changes required — their formats already carry the data. Optional upstream nicety, non-blocking: make `ltk_fantome`'s extractor round-trip `Layers` on extract; ltk-manager's own importer already does this itself.) + +## 5. Phase 3 — ltk-manager integration + +### 5.1 Dependencies (`src-tauri/Cargo.toml`) + +- `ltk_overlay` — during development, a git dep on the league-mod feature branch carrying Phase 2 (e.g. `{ git = "https://github.com/LeagueToolkit/league-mod.git", branch = "feat/string-override-application" }`); swap to `ltk_overlay = "0.4"` once published, before merging to main. +- `serde_yaml_ng` (maintained `serde_yaml` fork; upstream `serde_yaml` is archived) — only for reading `LeagueClientSettings.yaml`. + +### 5.2 Locale detection — new `src-tauri/src/utils/locale.rs` + +```rust +/// Reads install.globals.locale from /Config/LeagueClientSettings.yaml, +/// where is the parent of the resolved game dir. +/// Returns the locale lowercased (e.g. "en_us"). +pub fn detect_league_locale(game_dir: &Utf8Path) -> Option +``` + +- Deserialize only the needed nesting (`install.globals.locale`) with permissive structs (`#[serde(default)]` / `Option`s) — the file has many unrelated keys and Riot may add more. +- Fallback chain: YAML missing/unparseable → scan `Localized/` for `Global.*.wad.client`; exactly one → use it; else `"en_us"` + `tracing::warn!`. +- Unit tests: fixture YAML (copy the real structure), missing file, missing key. + +### 5.3 Settings toggle + +- `state.rs`: add `apply_string_overrides_to_all_locales: bool` to `Settings` with `#[serde(default)]` (default `false`). +- Update the settings TS type (`src/lib/tauri.ts` / generated bindings) and the settings route UI: a `Switch` (from `@/components`), label "Apply string overrides to all installed locales", description explaining default behavior (current locale only). + +### 5.4 Overlay wiring (`src-tauri/src/overlay/mod.rs`, `ensure_overlay`) + +```rust +let mode = if settings.apply_string_overrides_to_all_locales { + StringOverrideMode::AllInstalled +} else { + StringOverrideMode::Locales(vec![ + detect_league_locale(&game_dir).unwrap_or_else(|| "en_us".into()), + ]) +}; +// builder = builder.with_string_overrides(mode); +``` + +- The `ApplyingStringOverrides → OverlayStage::Strings` mapping already exists (`overlay/mod.rs:107`); verify the frontend label for the `Strings` stage in `useOverlayProgress.ts` reads sensibly (e.g. "Applying string overrides"). + +### 5.5 UI polish (`src/routes/workshop/$projectName/strings.tsx`) + +- Remove the "String overrides are not applied yet" `AlertBox` (lines 176–195) and the issue-#123 link. +- Replace with a short info note documenting semantics: `default` applies to all locales; a locale-specific entry beats `default`; `{hex}` keys target raw hashes. +- Optional (small): new command `get_league_locale` (follow the 7-step CLAUDE.md command checklist) so the editor can show "Detected game locale: en_US" and pre-select that locale tab. + +### 5.6 Verification pass (no expected code changes) + +- `get_enabled_mods_for_overlay` (`mods/library.rs:698–769`) needs no changes — providers already expose `mod_project()`. +- Confirm `with_blocked_wads(...)` never blocks `Localized/Global.*` WADs. +- Confirm workshop pack (modpkg) and fantome export still emit overrides (they do today; regression-check after dep bumps). +- `pnpm check` + `cargo clippy -p ltk-manager`. + +## 6. Phase 4 — optional follow-ups (separate PRs, not blocking) + +1. **Field-name autocomplete / browse** in the strings editor: download + cache CDragon `hashes.rst.xxh3.txt` (~7 MB) in app data; `Combobox` (from `@/components`) suggesting known keys, optionally showing the current default value by reading the game stringtable via `ltk_rst` (would add a direct `ltk_rst` dep to ltk-manager). +2. **Value preview/diff**: show the original string next to the override for the detected locale. +3. Upstream `ltk_fantome` extractor round-trip of `Layers` (see 4.6). + +## 7. Execution order & release checklist + +Development starts immediately on git deps (D2); publishing is deferred to the end. + +1. [x] league-mod: sync main; feature branch with `ltk_rst` git dep on `rst-api-rework`; implement Phase 2 (strings module, builder integration, fingerprints, fantome fix, tests). _Done: branch `feat/string-override-application`, commit `093541a`. Note: ltk-manager must take **all** league-mod crates that share types across the `ltk_overlay` API (`ltk_modpkg`, `ltk_mod_project`, `ltk_fantome`) from the same git branch — cargo treats git and crates.io copies as distinct crates._ +2. [x] ltk-manager: Phase 3 on a feature branch (`feat/apply-string-overrides`) with an `ltk_overlay` git dep on the league-mod feature branch. _Done: locale detection (`utils/locale.rs`), `apply_string_overrides_to_all_locales` setting + Patching-section toggle, builder wiring (`resolve_string_override_mode`), strings.tsx info box._ +3. [ ] Manual E2E on this machine (`C:\Riot Games\League of Legends`, locale `en_US`): + - Workshop project overriding a known key (pick one from the CDragon list, e.g. a champion/item name) → enable → build overlay → launch → verify in game. + - A `.fantome` mod with `StringOverrides` (covers the FantomeContent fix) and a packed/reimported `.modpkg`. + - Toggle "all locales" → only localized WADs rebuild; disable mod → overlay drops the localized WAD. +4. [ ] Release chain (once PR #138 review lands): merge #138 → bump + publish `ltk_rst 0.2.0` → swap `ltk_overlay`'s git dep to the version, merge + publish `ltk_overlay 0.4.0` → swap ltk-manager's git dep to the version → merge `feat/apply-string-overrides`. +5. [ ] Close ltk-manager #123 and league-mod #97. + +## 8. Risks & edge cases + +- **RST format drift**: `RstFormat::LATEST` (V5/38-bit/XXH3) matches the live game; if Riot shifts again, `ltk_rst` has detection/pinning (`Stringtable::reader().detect_hash_bits()`). Patch step should log the parsed format. +- **Hash-algorithm confusion**: RST key hashing (XXH3 since 14.15, masked to 38 bits — `ltk_rst` handles it) is distinct from WAD path hashing (xxh64-lowercase) and modpkg layer/wad hashing (xxh3). Never cross the helpers. (Note: cdragon-rs's xxh64/39-bit is outdated info — trust `ltk_rst`.) +- **Locale casing**: normalize map keys to lowercase; match WAD filenames case-insensitively (`en_US` in filenames vs `en_us` in data). +- **User locale not installed / YAML absent** (PBE, region moves, custom installs): fallback chain in 5.2; never fail the build over locale detection. +- **Mods shipping whole stringtable chunks**: handled by D4; document that key-level overrides always win over a raw shipped table. +- **Large localized WADs**: only rebuilt when overrides exist or change (4.3); otherwise untouched. +- **PR #138 churn**: development tracks the unreviewed branch, so review feedback may change the `ltk_rst` API (or force-push the branch) under us. Cargo.lock pins the rev, so breakage only lands on a deliberate `cargo update -p ltk_rst`; budget for small adaptation in `strings.rs`. The merge + publish is still required before anything ships to main (D2). diff --git a/package.json b/package.json index ef521953..422b93e3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@tanstack/react-query": "^5.100.1", "@tanstack/react-query-devtools": "^5.100.1", "@tanstack/react-router": "^1.168.23", + "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.24", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-deep-link": "^2.4.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 156c236b..7f1354a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@tanstack/react-router': specifier: ^1.168.23 version: 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -913,6 +916,13 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.24': resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} peerDependencies: @@ -957,6 +967,10 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tanstack/virtual-core@3.14.0': resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} @@ -3929,6 +3943,12 @@ snapshots: react-dom: 19.2.5(react@19.2.5) use-sync-external-store: 1.6.0(react@19.2.5) + '@tanstack/react-table@8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + '@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/virtual-core': 3.14.0 @@ -3992,6 +4012,8 @@ snapshots: '@tanstack/store@0.9.3': {} + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.14.0': {} '@tanstack/virtual-file-routes@1.161.7': {} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aea86a85..4ff131a8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,17 +26,20 @@ tauri-plugin-single-instance = "2" tauri-plugin-autostart = "2" tauri-plugin-window-state = "2" -ltk_modpkg = { version = "0.5.0", features = ["project"] } -ltk_mod_project = { version = "0.4.1" } +ltk_modpkg = { version = "0.6.0", features = ["project"] } +ltk_mod_project = { version = "0.5.0" } ltk_mod_core = { version = "0.1.0" } -ltk_fantome = { version = "0.5.1" } -ltk_overlay = { version = "0.3.1" } +ltk_fantome = { version = "0.6.0" } +ltk_overlay = { version = "0.4.0" } +ltk_rst = "0.2.0" ltk_wad = "0.2.12" ltk_file = { version = "0.2.8", features = ["serde"] } camino = "1.2" +serde_yaml_ng = "0.10" semver = "1.0" toml = "0.8" +indexmap = { version = "2", features = ["serde"] } tokio = { version = "1", features = ["full"] } libloading = "0.9.0" @@ -45,7 +48,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" regex = "1" -ts-rs = { version = "12", features = ["chrono-impl", "serde-json-impl"] } +ts-rs = { version = "12", features = ["chrono-impl", "serde-json-impl", "indexmap-impl"] } thiserror = "2" anyhow = "1" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 542f1e66..6bc7c906 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -29,6 +29,7 @@ mod profiles; mod settings; mod shell; mod storage; +mod strings; mod workshop; pub use app::*; @@ -44,4 +45,5 @@ pub use profiles::*; pub use settings::*; pub use shell::*; pub use storage::*; +pub use strings::*; pub use workshop::*; diff --git a/src-tauri/src/commands/strings.rs b/src-tauri/src/commands/strings.rs new file mode 100644 index 00000000..d8a11942 --- /dev/null +++ b/src-tauri/src/commands/strings.rs @@ -0,0 +1,37 @@ +use crate::error::{AppError, AppResult, IpcResult, MutexResultExt}; +use crate::state::SettingsState; +use crate::strings::{StringKeyIndexState, StringKeySearchResult}; +use tauri::{AppHandle, State}; + +/// Search known stringtable field names for the workshop strings editor. +/// +/// The first call builds the suggestion index (downloading the CommunityDragon +/// key list when missing and reading the game stringtable for value previews), +/// so it can take a few seconds; subsequent calls are instant. +#[tauri::command] +pub fn search_string_keys( + query: String, + limit: Option, + app_handle: AppHandle, + settings: State, + index: State, +) -> IpcResult { + search_string_keys_inner(&query, limit, &app_handle, &settings, &index).into() +} + +fn search_string_keys_inner( + query: &str, + limit: Option, + app_handle: &AppHandle, + settings: &SettingsState, + index: &StringKeyIndexState, +) -> AppResult { + let settings = settings.0.lock().mutex_err()?.clone(); + let cache_dir = crate::state::get_app_data_dir(app_handle) + .ok_or_else(|| AppError::Other("Could not determine app data directory".to_string()))? + .join("hashes"); + + let index = index.get_or_build(&cache_dir, &settings)?; + let limit = limit.unwrap_or(50).min(200) as usize; + Ok(index.search(query, limit)) +} diff --git a/src-tauri/src/commands/workshop.rs b/src-tauri/src/commands/workshop.rs index 03b2b6d0..dfaccbca 100644 --- a/src-tauri/src/commands/workshop.rs +++ b/src-tauri/src/commands/workshop.rs @@ -5,6 +5,7 @@ use crate::workshop::{ ImportGitRepoArgs, PackProjectArgs, PackResult, SaveProjectConfigArgs, ValidationResult, WorkshopLayerInfo, WorkshopProject, WorkshopState, }; +use indexmap::IndexMap; use std::collections::HashMap; use tauri::State; @@ -166,7 +167,7 @@ pub fn get_project_thumbnail( pub fn save_layer_string_overrides( project_path: String, layer_name: String, - string_overrides: HashMap>, + string_overrides: IndexMap>, workshop: State, ) -> IpcResult { workshop diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 124e9b00..21113a82 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -17,6 +17,7 @@ pub mod patcher; mod setup; mod state; mod storage; +mod strings; mod tray; mod utils; mod workshop; @@ -140,6 +141,7 @@ fn main() { commands::remove_project_thumbnail, commands::get_project_thumbnail, commands::save_layer_string_overrides, + commands::search_string_keys, commands::get_layer_content_path, commands::get_layer_info, commands::create_project_layer, diff --git a/src-tauri/src/overlay/mod.rs b/src-tauri/src/overlay/mod.rs index 8f7c214e..ab9a16d1 100644 --- a/src-tauri/src/overlay/mod.rs +++ b/src-tauri/src/overlay/mod.rs @@ -93,12 +93,16 @@ impl ModLibrary { let blocked_wads = resolve_blocked_wads(settings, &available_wads); tracing::info!("Overlay: blocked_wads count={}", blocked_wads.len()); + let string_override_mode = resolve_string_override_mode(settings, &game_dir); + tracing::info!("Overlay: string_override_mode={:?}", string_override_mode); + Self::clean_corrupt_overlay_state(&utf8_state_dir); let app_handle_clone = self.app_handle().clone(); let mut builder = ltk_overlay::OverlayBuilder::new(utf8_game_dir, utf8_overlay_root, utf8_state_dir) .with_blocked_wads(blocked_wads) + .with_string_overrides(string_override_mode) .with_progress(move |progress| { let stage = match progress.stage { ltk_overlay::OverlayStage::Indexing => OverlayStage::Indexing, @@ -308,6 +312,27 @@ impl ModLibrary { } } +/// Resolve which locales mods' string overrides should be applied to. +/// +/// With the "all locales" setting on, every installed locale is patched. +/// Otherwise only the locale the League client is configured to use — read +/// from `LeagueClientSettings.yaml`, falling back to the sole installed locale +/// and finally to `en_us` so string overrides still apply on unusual installs. +pub(crate) fn resolve_string_override_mode( + settings: &Settings, + game_dir: &Path, +) -> ltk_overlay::StringOverrideMode { + if settings.apply_string_overrides_to_all_locales { + return ltk_overlay::StringOverrideMode::AllInstalled; + } + + let locale = crate::utils::locale::detect_league_locale(game_dir).unwrap_or_else(|| { + tracing::warn!("Falling back to 'en_us' for string overrides"); + "en_us".to_string() + }); + ltk_overlay::StringOverrideMode::Locales(vec![locale]) +} + /// Resolve the user's blocklist settings into a concrete, deduped list of WAD /// filenames to hand to `ltk_overlay::OverlayBuilder::with_blocked_wads`. /// diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs index fc490449..e751545d 100644 --- a/src-tauri/src/setup.rs +++ b/src-tauri/src/setup.rs @@ -49,6 +49,7 @@ pub fn run(app: &mut tauri::App) -> Result<(), Box> { app.manage(settings_state); app.manage(patcher_state); app.manage(LinkedBinState::default()); + app.manage(crate::strings::StringKeyIndexState::default()); app.manage(mod_library); app.manage(workshop); app.manage(hotkey_manager); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7afda860..6c14b39c 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -266,6 +266,11 @@ pub struct Settings { /// downgraded to warnings and flagged mods load anyway. Default: true. #[serde(default = "default_true")] pub enforce_skinhack_scan: bool, + /// Whether mods' string overrides are applied to every installed locale + /// instead of only the locale the League client is configured to use. + /// Default: false (current locale only). + #[serde(default)] + pub apply_string_overrides_to_all_locales: bool, } impl Default for Settings { @@ -301,6 +306,7 @@ impl Default for Settings { elevate_injector: false, auto_categorization_enabled: true, enforce_skinhack_scan: true, + apply_string_overrides_to_all_locales: false, } } } @@ -328,6 +334,7 @@ mod tests { assert!(settings.wad_blocklist.is_empty()); assert!(settings.auto_categorization_enabled); assert!(settings.enforce_skinhack_scan); + assert!(!settings.apply_string_overrides_to_all_locales); } #[test] @@ -370,6 +377,7 @@ mod tests { elevate_injector: false, auto_categorization_enabled: true, enforce_skinhack_scan: true, + apply_string_overrides_to_all_locales: false, }; let json = serde_json::to_string(&settings).unwrap(); let deserialized: Settings = serde_json::from_str(&json).unwrap(); diff --git a/src-tauri/src/strings/mod.rs b/src-tauri/src/strings/mod.rs new file mode 100644 index 00000000..ec0d4579 --- /dev/null +++ b/src-tauri/src/strings/mod.rs @@ -0,0 +1,333 @@ +//! Autocomplete index for stringtable field names. +//! +//! Combines the community-maintained key-name list (CommunityDragon +//! `hashes.rst.xxh3.txt`) with current values from the game's own stringtable +//! for the detected locale, so the workshop strings editor can suggest valid +//! field names and show what each one currently says in game. + +use crate::error::{AppError, AppResult, MutexResultExt}; +use crate::state::Settings; +use serde::Serialize; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use ts_rs::TS; + +const HASH_LIST_URL: &str = + "https://raw.githubusercontent.com/CommunityDragon/Data/master/hashes/lol/hashes.rst.xxh3.txt"; +const HASH_LIST_FILE: &str = "hashes.rst.xxh3.txt"; +const HASH_LIST_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +/// One autocomplete suggestion for a stringtable field. +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct StringKeySuggestion { + /// Field name, e.g. `game_character_displayname_ahri`. + pub key: String, + /// What the game's stringtable currently says for this field in the + /// indexed locale, when the key exists there. + pub value: Option, +} + +/// Result of a suggestion query. +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct StringKeySearchResult { + pub suggestions: Vec, + /// Total number of known field names in the index. + pub total_keys: u32, + /// Locale whose stringtable supplied the `value` previews, when available. + pub locale: Option, +} + +struct IndexEntry { + key: String, + value: Option, +} + +/// In-memory suggestion index: known field names (sorted) joined with the +/// game's current values. +pub struct StringKeyIndex { + entries: Vec, + locale: Option, +} + +impl StringKeyIndex { + /// Build the index from the cached/downloaded key list and, best-effort, + /// the game stringtable of the detected locale. + fn build(cache_dir: &Path, settings: &Settings) -> AppResult { + let key_list = load_key_list(cache_dir)?; + + let table = load_game_stringtable(settings); + let locale = table.as_ref().map(|(locale, _)| locale.clone()); + + let mut entries: Vec = key_list + .lines() + .filter_map(|line| { + let (hex, name) = line.split_once(' ')?; + let name = name.trim(); + if name.is_empty() { + return None; + } + let value = table.as_ref().and_then(|(_, table)| { + let hash = u64::from_str_radix(hex.trim(), 16).ok()?; + table.get(hash).map(str::to_string) + }); + Some(IndexEntry { + key: name.to_string(), + value, + }) + }) + .collect(); + + entries.sort_by(|a, b| a.key.cmp(&b.key)); + entries.dedup_by(|a, b| a.key == b.key); + + tracing::info!( + "String key index built: {} keys, values from locale {:?}", + entries.len(), + locale + ); + + Ok(Self { entries, locale }) + } + + /// Rank matching keys for `query`: prefix matches first, then substring + /// matches, both in alphabetical order. An empty query lists the first + /// `limit` keys for discovery. + pub fn search(&self, query: &str, limit: usize) -> StringKeySearchResult { + let query = query.trim().to_lowercase(); + + let mut prefix: Vec<&IndexEntry> = Vec::new(); + let mut contains: Vec<&IndexEntry> = Vec::new(); + if query.is_empty() { + prefix.extend(self.entries.iter().take(limit)); + } else { + for entry in &self.entries { + if prefix.len() >= limit { + break; + } + if entry.key.starts_with(&query) { + prefix.push(entry); + } else if contains.len() < limit && entry.key.contains(&query) { + contains.push(entry); + } + } + } + + let suggestions = prefix + .into_iter() + .chain(contains) + .take(limit) + .map(|entry| StringKeySuggestion { + key: entry.key.clone(), + value: entry.value.clone(), + }) + .collect(); + + StringKeySearchResult { + suggestions, + total_keys: self.entries.len() as u32, + locale: self.locale.clone(), + } + } +} + +/// Lazily-built, app-managed [`StringKeyIndex`]. +#[derive(Default)] +pub struct StringKeyIndexState(Mutex>>); + +impl StringKeyIndexState { + /// Return the index, building it on first use. The lock is held for the + /// duration of the build so concurrent callers wait instead of racing a + /// second download/parse. + pub fn get_or_build( + &self, + cache_dir: &Path, + settings: &Settings, + ) -> AppResult> { + let mut slot = self.0.lock().mutex_err()?; + if let Some(index) = slot.as_ref() { + return Ok(Arc::clone(index)); + } + let index = Arc::new(StringKeyIndex::build(cache_dir, settings)?); + *slot = Some(Arc::clone(&index)); + Ok(index) + } +} + +/// Read the key list from the cache, refreshing it from CommunityDragon when +/// missing or older than [`HASH_LIST_MAX_AGE`]. A failed refresh falls back to +/// a stale cache when one exists. +fn load_key_list(cache_dir: &Path) -> AppResult { + let path = cache_dir.join(HASH_LIST_FILE); + + let needs_refresh = match std::fs::metadata(&path) { + Ok(meta) => meta + .modified() + .ok() + .and_then(|t| t.elapsed().ok()) + .is_none_or(|age| age > HASH_LIST_MAX_AGE), + Err(_) => true, + }; + + if needs_refresh { + if let Err(e) = download_key_list(&path) { + tracing::warn!("Failed to refresh string key list: {}", e); + } + } + + std::fs::read_to_string(&path).map_err(|_| { + AppError::Other( + "String key list is not available yet - it downloads automatically when online" + .to_string(), + ) + }) +} + +fn download_key_list(dest: &Path) -> AppResult { + tracing::info!("Downloading string key list from CommunityDragon"); + let response = reqwest::blocking::get(HASH_LIST_URL) + .and_then(|r| r.error_for_status()) + .map_err(|e| AppError::Other(format!("Failed to download string key list: {e}")))?; + let body = response + .bytes() + .map_err(|e| AppError::Other(format!("Failed to download string key list: {e}")))?; + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let temp = dest.with_extension("txt.tmp"); + std::fs::write(&temp, &body)?; + std::fs::rename(&temp, dest)?; + tracing::info!("String key list saved ({} bytes)", body.len()); + Ok(dest.to_path_buf()) +} + +/// Best-effort load of the game's `lol.stringtable` for the detected locale. +/// Any failure (unset league path, missing WAD/chunk, parse error) just means +/// suggestions come without value previews. +fn load_game_stringtable(settings: &Settings) -> Option<(String, ltk_rst::Stringtable)> { + let game_dir = match crate::utils::game::resolve_game_dir(settings) { + Ok(dir) => dir, + Err(e) => { + tracing::debug!("String index: game dir unavailable: {}", e); + return None; + } + }; + let locale = + crate::utils::locale::detect_league_locale(&game_dir).unwrap_or_else(|| "en_us".into()); + + let wad_path = find_localized_global_wad(&game_dir, &locale)?; + let file = std::fs::File::open(&wad_path).ok()?; + let mut wad = ltk_wad::Wad::mount(file).ok()?; + + let chunk_path = format!("data/menu/{locale}/lol.stringtable"); + let chunk_hash = + ltk_modpkg::utils::hash_chunk_name(<k_modpkg::utils::normalize_chunk_path(&chunk_path)); + let chunk = *wad.chunks().get(chunk_hash)?; + let bytes = wad.load_chunk_decompressed(&chunk).ok()?; + + match ltk_rst::Stringtable::from_reader(&mut Cursor::new(&bytes[..])) { + Ok(table) => Some((locale, table)), + Err(e) => { + tracing::warn!("String index: failed to parse {}: {}", chunk_path, e); + None + } + } +} + +/// Locate `Localized/Global.{locale}.wad.client` case-insensitively. +fn find_localized_global_wad(game_dir: &Path, locale: &str) -> Option { + let localized_dir = game_dir.join("DATA").join("FINAL").join("Localized"); + let wanted = format!("global.{}.wad.client", locale.to_lowercase()); + std::fs::read_dir(localized_dir).ok()?.find_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name(); + if name.to_str()?.eq_ignore_ascii_case(&wanted) { + Some(entry.path()) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn index_from(entries: &[(&str, Option<&str>)]) -> StringKeyIndex { + StringKeyIndex { + entries: entries + .iter() + .map(|(key, value)| IndexEntry { + key: key.to_string(), + value: value.map(str::to_string), + }) + .collect(), + locale: Some("en_us".to_string()), + } + } + + #[test] + fn search_ranks_prefix_before_substring() { + let index = index_from(&[ + ("ahri_lore", None), + ("game_character_displayname_ahri", Some("Ahri")), + ("game_character_displayname_annie", Some("Annie")), + ]); + + let result = index.search("ahri", 10); + let keys: Vec<&str> = result.suggestions.iter().map(|s| s.key.as_str()).collect(); + assert_eq!(keys, vec!["ahri_lore", "game_character_displayname_ahri"]); + assert_eq!(result.suggestions[1].value.as_deref(), Some("Ahri")); + assert_eq!(result.total_keys, 3); + assert_eq!(result.locale.as_deref(), Some("en_us")); + } + + #[test] + fn search_is_case_insensitive_and_limited() { + let index = index_from(&[("aaa", None), ("aab", None), ("aac", None)]); + let result = index.search("AA", 2); + assert_eq!(result.suggestions.len(), 2); + } + + #[test] + fn empty_query_lists_from_start() { + let index = index_from(&[("aaa", None), ("bbb", None)]); + let result = index.search(" ", 1); + assert_eq!(result.suggestions[0].key, "aaa"); + } + + #[test] + fn build_parses_key_list_and_joins_values() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path().join("hashes"); + std::fs::create_dir_all(&cache_dir).unwrap(); + + // Build a table with a known key, then reference it from the list by + // its full XXH3 hash the way the CommunityDragon file does. + let mut table = ltk_rst::Stringtable::new(); + table.insert_str("game_client_quit", "Quit"); + let full_hash = xxh3_full("game_client_quit"); + std::fs::write( + cache_dir.join(HASH_LIST_FILE), + format!("{full_hash:016x} game_client_quit\ndeadbeefdeadbeef unknown_key\n"), + ) + .unwrap(); + + // No league path configured -> no value previews, keys still indexed. + let index = StringKeyIndex::build(&cache_dir, &Settings::default()).unwrap(); + assert_eq!(index.entries.len(), 2); + assert!(index.entries.iter().all(|e| e.value.is_none())); + assert_eq!(index.locale, None); + } + + fn xxh3_full(key: &str) -> u64 { + // Matches CDragon/ltk_rst hashing: XXH3-64 over the lowercased key. + xxhash_rust::xxh3::xxh3_64(key.to_lowercase().as_bytes()) + } +} diff --git a/src-tauri/src/utils/locale.rs b/src-tauri/src/utils/locale.rs new file mode 100644 index 00000000..dcb136c5 --- /dev/null +++ b/src-tauri/src/utils/locale.rs @@ -0,0 +1,188 @@ +//! Detection of the League client's configured locale. + +use std::path::Path; + +/// Minimal, permissive projection of `LeagueClientSettings.yaml` — only the +/// `install.globals.locale` path is read; everything else is ignored and every +/// level is optional so Riot adding/removing keys can't break parsing. +#[derive(Debug, Default, serde::Deserialize)] +#[serde(default)] +struct LeagueClientSettings { + install: InstallSection, +} + +#[derive(Debug, Default, serde::Deserialize)] +#[serde(default)] +struct InstallSection { + globals: GlobalsSection, +} + +#[derive(Debug, Default, serde::Deserialize)] +#[serde(default)] +struct GlobalsSection { + locale: Option, +} + +/// Detect the locale the League client is configured to use, lowercased +/// (e.g. `"en_us"`). +/// +/// Reads `install.globals.locale` from `/Config/LeagueClientSettings.yaml`, +/// where the league root is the parent of `game_dir`. When the file or key is +/// unavailable, falls back to the game's installed localized WADs +/// (`DATA/FINAL/Localized/Global.{locale}.wad.client`) if exactly one locale is +/// installed. Returns `None` when neither source is conclusive. +pub(crate) fn detect_league_locale(game_dir: &Path) -> Option { + if let Some(locale) = locale_from_client_settings(game_dir) { + return Some(locale); + } + + match installed_locales(game_dir).as_slice() { + [single] => { + tracing::info!( + "Locale not found in LeagueClientSettings.yaml; using sole installed locale '{}'", + single + ); + Some(single.clone()) + } + [] => { + tracing::warn!("Could not detect League locale: no localized Global WADs found"); + None + } + multiple => { + tracing::warn!( + "Could not detect League locale: multiple locales installed ({:?})", + multiple + ); + None + } + } +} + +fn locale_from_client_settings(game_dir: &Path) -> Option { + let config_path = game_dir + .parent()? + .join("Config") + .join("LeagueClientSettings.yaml"); + + let contents = match std::fs::read_to_string(&config_path) { + Ok(contents) => contents, + Err(e) => { + tracing::debug!("Could not read {}: {}", config_path.display(), e); + return None; + } + }; + + let settings: LeagueClientSettings = match serde_yaml_ng::from_str(&contents) { + Ok(settings) => settings, + Err(e) => { + tracing::warn!("Failed to parse {}: {}", config_path.display(), e); + return None; + } + }; + + let locale = settings.install.globals.locale?.trim().to_lowercase(); + if locale.is_empty() { + return None; + } + tracing::info!("Detected League locale '{}' from client settings", locale); + Some(locale) +} + +/// Locales with a `Global.{locale}.wad.client` under `DATA/FINAL/Localized`, +/// lowercased and sorted. +fn installed_locales(game_dir: &Path) -> Vec { + let localized_dir = game_dir.join("DATA").join("FINAL").join("Localized"); + let Ok(entries) = std::fs::read_dir(&localized_dir) else { + return Vec::new(); + }; + + let mut locales: Vec = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_str()?.to_ascii_lowercase(); + let locale = name + .strip_prefix("global.")? + .strip_suffix(".wad.client")? + .to_string(); + if locale.is_empty() || locale.contains('.') { + return None; + } + Some(locale) + }) + .collect(); + locales.sort(); + locales.dedup(); + locales +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirrors the real file's structure, including unrelated keys. + const CLIENT_SETTINGS_YAML: &str = r#" +install: + crash_reporting: + enabled: true + type: "crashpad" + gameflow-patcher-lock: null + globals: + locale: "en_US" + region: "EUW" + patcher: + locales: + - "en_US" +"#; + + fn league_root_with(yaml: Option<&str>, locales: &[&str]) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + if let Some(yaml) = yaml { + let config_dir = tmp.path().join("Config"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("LeagueClientSettings.yaml"), yaml).unwrap(); + } + let localized = tmp.path().join("Game/DATA/FINAL/Localized"); + std::fs::create_dir_all(&localized).unwrap(); + for locale in locales { + std::fs::write(localized.join(format!("Global.{locale}.wad.client")), b"").unwrap(); + } + tmp + } + + #[test] + fn reads_locale_from_client_settings() { + let root = league_root_with(Some(CLIENT_SETTINGS_YAML), &["en_US", "ko_KR"]); + let locale = detect_league_locale(&root.path().join("Game")); + assert_eq!(locale.as_deref(), Some("en_us")); + } + + #[test] + fn falls_back_to_sole_installed_locale() { + let root = league_root_with(None, &["ko_KR"]); + let locale = detect_league_locale(&root.path().join("Game")); + assert_eq!(locale.as_deref(), Some("ko_kr")); + } + + #[test] + fn ambiguous_install_without_settings_returns_none() { + let root = league_root_with(None, &["en_US", "ko_KR"]); + assert_eq!(detect_league_locale(&root.path().join("Game")), None); + } + + #[test] + fn malformed_yaml_falls_back_to_scan() { + let root = league_root_with(Some(":: not yaml ["), &["en_US"]); + let locale = detect_league_locale(&root.path().join("Game")); + assert_eq!(locale.as_deref(), Some("en_us")); + } + + #[test] + fn missing_locale_key_falls_back_to_scan() { + let root = league_root_with( + Some("install:\n globals:\n region: \"EUW\"\n"), + &["en_US"], + ); + let locale = detect_league_locale(&root.path().join("Game")); + assert_eq!(locale.as_deref(), Some("en_us")); + } +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 4d82f5ea..c3b4041f 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod game; +pub mod locale; pub mod native; diff --git a/src-tauri/src/workshop/layers.rs b/src-tauri/src/workshop/layers.rs index 74934926..b2a2ea86 100644 --- a/src-tauri/src/workshop/layers.rs +++ b/src-tauri/src/workshop/layers.rs @@ -4,6 +4,7 @@ use super::{ }; use crate::error::{AppError, AppResult}; use camino::Utf8Path; +use indexmap::IndexMap; use ltk_mod_project::ModProject; use ltk_mod_project::ModProjectLayer; use ltk_wad::{HexPathResolver, Wad, WadExtractor}; @@ -48,7 +49,7 @@ pub(crate) fn create_layer_at_path( display_name, priority: max_priority + 1, description, - string_overrides: HashMap::new(), + string_overrides: IndexMap::new(), }); let json_config_path = path.join("mod.config.json"); @@ -244,7 +245,7 @@ pub(crate) fn reorder_layers_at_path( pub(crate) fn save_layer_string_overrides_at_path( path: &Path, layer_name: &str, - string_overrides: HashMap>, + string_overrides: IndexMap>, ) -> AppResult { let mut mod_project = ModProject::load(path)?; @@ -491,7 +492,7 @@ impl Workshop { &self, project_path: &str, layer_name: &str, - string_overrides: HashMap>, + string_overrides: IndexMap>, ) -> AppResult { let path = PathBuf::from(project_path); if !path.exists() { @@ -585,7 +586,6 @@ impl Workshop { mod tests { use super::*; use crate::error::AppError; - use std::collections::HashMap; fn make_project_with_layers(dir: &std::path::Path, layers: Vec) { let mod_project = ltk_mod_project::ModProject { @@ -692,7 +692,7 @@ mod tests { display_name: None, priority: 1, description: None, - string_overrides: HashMap::new(), + string_overrides: IndexMap::new(), }, ], ); @@ -720,7 +720,7 @@ mod tests { display_name: None, priority: 1, description: None, - string_overrides: HashMap::new(), + string_overrides: IndexMap::new(), }, ], ); @@ -750,14 +750,14 @@ mod tests { display_name: None, priority: 1, description: None, - string_overrides: HashMap::new(), + string_overrides: IndexMap::new(), }, ModProjectLayer { name: "vfx".to_string(), display_name: None, priority: 2, description: None, - string_overrides: HashMap::new(), + string_overrides: IndexMap::new(), }, ], ); @@ -780,14 +780,14 @@ mod tests { display_name: None, priority: 1, description: None, - string_overrides: HashMap::new(), + string_overrides: IndexMap::new(), }, ModProjectLayer { name: "vfx".to_string(), display_name: None, priority: 2, description: None, - string_overrides: HashMap::new(), + string_overrides: IndexMap::new(), }, ], ); diff --git a/src-tauri/src/workshop/mod.rs b/src-tauri/src/workshop/mod.rs index 18c54387..de9d208e 100644 --- a/src-tauri/src/workshop/mod.rs +++ b/src-tauri/src/workshop/mod.rs @@ -8,9 +8,9 @@ pub use content::ContentTree; use crate::error::{AppError, AppResult}; use crate::state::Settings; use chrono::{DateTime, Utc}; +use indexmap::IndexMap; use ltk_mod_project::{ModProject, ModProjectAuthor}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use tauri::AppHandle; @@ -105,7 +105,7 @@ pub struct WorkshopLayer { pub priority: i32, pub description: Option, #[serde(default)] - pub string_overrides: HashMap>, + pub string_overrides: IndexMap>, } /// Runtime info about a layer's content directory, fetched separately from config. diff --git a/src-tauri/src/workshop/packing.rs b/src-tauri/src/workshop/packing.rs index 47e0368f..786bb4e3 100644 --- a/src-tauri/src/workshop/packing.rs +++ b/src-tauri/src/workshop/packing.rs @@ -263,7 +263,7 @@ impl Workshop { #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; + use indexmap::IndexMap; fn make_valid_project(dir: &std::path::Path) { let mod_project = ltk_mod_project::ModProject { @@ -494,7 +494,7 @@ mod tests { display_name: Some("Chroma".to_string()), priority: 1, description: None, - string_overrides: HashMap::new(), + string_overrides: IndexMap::new(), }], thumbnail: None, }; diff --git a/src/components/Combobox.tsx b/src/components/Combobox.tsx index a0df3bbc..934193be 100644 --- a/src/components/Combobox.tsx +++ b/src/components/Combobox.tsx @@ -34,7 +34,7 @@ export const ComboboxInput = forwardRef( { + /** Extra classes for this column's header cell. */ + headerClassName?: string; + /** Extra classes for this column's body cells. */ + cellClassName?: string; + } +} + +export interface DataTableProps { + // react-table types its own `columns` option this way; the value type varies + // per column, so a single generic can't capture it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columns: ColumnDef[]; + data: TData[]; + getRowId?: (row: TData) => string; + meta?: TableMeta; + /** Controlled global-filter value (e.g. a search box the parent owns). */ + globalFilter?: string; + globalFilterFn?: FilterFn; + initialSorting?: SortingState; + /** Shown in place of the body when there are no rows to render. */ + emptyState?: ReactNode; + className?: string; + /** Applied to the scroll container — use for a max-height, e.g. `max-h-112`. */ + scrollClassName?: string; +} + +/** + * Headless-table renderer wired to the app's `Table` primitives: it owns the + * `useReactTable` instance, core/sorted/filtered row models, a sticky header, + * and per-column class hooks (`columnDef.meta.headerClassName`/`cellClassName`). + * Callers supply only columns, data, and options. + */ +export function DataTable({ + columns, + data, + getRowId, + meta, + globalFilter, + globalFilterFn, + initialSorting = [], + emptyState, + className, + scrollClassName, +}: DataTableProps) { + const [sorting, setSorting] = useState(initialSorting); + + const table = useReactTable({ + data, + columns, + state: { sorting, globalFilter }, + onSortingChange: setSorting, + globalFilterFn, + getRowId, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + meta, + }); + + const rows = table.getRowModel().rows; + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {rows.map((row, index) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + +
+ + {rows.length === 0 && emptyState} +
+ ); +} + +export interface DataTableColumnHeaderProps { + column: Column; + /** Optional explanatory hint shown as a `HintIcon` beside the label. */ + hint?: ReactNode; + children: ReactNode; +} + +/** + * Header-cell content for a `DataTable` column: a click-to-sort button (when the + * column is sortable) with an optional hint icon beside it. The icon sits + * outside the button so the two stay separate interactive controls. + */ +export function DataTableColumnHeader({ + column, + hint, + children, +}: DataTableColumnHeaderProps) { + return ( +
+ {column.getCanSort() && ( + column.toggleSorting()}> + {children} + + )} + {!column.getCanSort() && children} + {hint && } +
+ ); +} diff --git a/src/components/ExternalLink.tsx b/src/components/ExternalLink.tsx new file mode 100644 index 00000000..38edd05c --- /dev/null +++ b/src/components/ExternalLink.tsx @@ -0,0 +1,32 @@ +import { ExternalLink as ExternalLinkIcon } from "lucide-react"; +import { type AnchorHTMLAttributes, forwardRef } from "react"; +import { twMerge } from "tailwind-merge"; + +export interface ExternalLinkProps extends Omit< + AnchorHTMLAttributes, + "target" | "rel" +> { + /** Hide the trailing external-link icon. */ + hideIcon?: boolean; +} + +export const ExternalLink = forwardRef( + ({ children, className, hideIcon, ...props }, ref) => { + return ( + + {children} + {!hideIcon && } + + ); + }, +); +ExternalLink.displayName = "ExternalLink"; diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index 75bccadd..0e73c52e 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -95,7 +95,7 @@ export const FieldControl = forwardRef( ( >( + ({ className, ...props }, ref) => ( + + ), +); +TableRoot.displayName = "Table.Root"; + +// Header +export const TableHeader = forwardRef>( + ({ className, ...props }, ref) => , +); +TableHeader.displayName = "Table.Header"; + +// Body +export const TableBody = forwardRef>( + ({ className, ...props }, ref) => , +); +TableBody.displayName = "Table.Body"; + +// Row +export const TableRow = forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableRow.displayName = "Table.Row"; + +// Head cell +export const TableHead = forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +TableHead.displayName = "Table.Head"; + +// Body cell +export const TableCell = forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableCell.displayName = "Table.Cell"; + +// Sort button — placed inside a Head cell to make a column sortable. Kept +// separate from Head so non-interactive affordances (e.g. a hint icon) can sit +// beside it without nesting buttons. +export interface TableSortButtonProps extends ComponentPropsWithoutRef<"button"> { + /** Current sort of this column: `"asc"`, `"desc"`, or `false` when unsorted. */ + direction: "asc" | "desc" | false; + children: ReactNode; +} + +export const TableSortButton = forwardRef( + ({ direction, className, children, ...props }, ref) => { + const Icon = match(direction) + .with("asc", () => ChevronUp) + .with("desc", () => ChevronDown) + .otherwise(() => ChevronsUpDown); + + return ( + + ); + }, +); +TableSortButton.displayName = "Table.SortButton"; + +// Compound export +export const Table = { + Root: TableRoot, + Header: TableHeader, + Body: TableBody, + Row: TableRow, + Head: TableHead, + Cell: TableCell, + SortButton: TableSortButton, +}; diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 81475132..309898ab 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -27,7 +27,7 @@ export interface TabsListProps extends Omit, "cl export const TabsList = forwardRef( ({ variant = "default", className, children, ...props }, ref) => { const variantClasses = match(variant) - .with("default", () => "border-b border-surface-700 gap-0") + .with("default", () => "border-b border-surface-700 gap-0 overflow-x-auto") .with("pills", () => "bg-surface-800 rounded-lg p-1 gap-1") .exhaustive(); @@ -54,13 +54,13 @@ export interface TabsTabProps extends Omit { export const TabsTab = forwardRef( ({ variant = "default", className, children, ...props }, ref) => { const baseClasses = - "relative px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-900 disabled:pointer-events-none disabled:opacity-50"; + "relative shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-900 disabled:pointer-events-none disabled:opacity-50"; const variantClasses = match(variant) .with( "default", () => - "text-surface-400 hover:text-surface-200 data-[active]:text-accent-400 data-[active]:after:absolute data-[active]:after:inset-x-0 data-[active]:after:-bottom-px data-[active]:after:h-0.5 data-[active]:after:bg-accent-500", + "text-surface-400 hover:text-surface-200 data-[active]:text-accent-400 data-[active]:after:absolute data-[active]:after:inset-x-0 data-[active]:after:bottom-0 data-[active]:after:h-0.5 data-[active]:after:bg-accent-500", ) .with( "pills", diff --git a/src/components/index.ts b/src/components/index.ts index bc449d93..9d9cf966 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,7 +5,9 @@ export * from "./ButtonGroup"; export * from "./Checkbox"; export * from "./Combobox"; export * from "./ContextMenu"; +export * from "./DataTable"; export * from "./Dialog"; +export * from "./ExternalLink"; export * from "./FormField"; export * from "./HintIcon"; export * from "./Kbd"; @@ -22,6 +24,7 @@ export * from "./Skeleton"; export * from "./Slider"; export * from "./Spinner"; export * from "./Switch"; +export * from "./Table"; export * from "./Tabs"; export * from "./Toast"; export * from "./ToastProvider"; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ebc58232..01f1f16a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,5 +1,6 @@ export { useAutoStartPatcher } from "./useAutoStartPatcher"; export { useClickOutside } from "./useClickOutside"; +export { useDebouncedValue } from "./useDebouncedValue"; export { useHddWarning } from "./useHddWarning"; export { usePlatformSupport } from "./usePlatformSupport"; export { usePrevious } from "./usePrevious"; diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..48c5b64f --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +/** + * Returns `value` after it has stopped changing for `delayMs`. + */ +export function useDebouncedValue(value: T, delayMs = 200): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(handle); + }, [value, delayMs]); + + return debounced; +} diff --git a/src/lib/bindings/Settings.ts b/src/lib/bindings/Settings.ts index 31d9b598..6bbed308 100644 --- a/src/lib/bindings/Settings.ts +++ b/src/lib/bindings/Settings.ts @@ -123,4 +123,10 @@ export type Settings = { * downgraded to warnings and flagged mods load anyway. Default: true. */ enforceSkinhackScan: boolean; + /** + * Whether mods' string overrides are applied to every installed locale + * instead of only the locale the League client is configured to use. + * Default: false (current locale only). + */ + applyStringOverridesToAllLocales: boolean; }; diff --git a/src/lib/bindings/StringKeySearchResult.ts b/src/lib/bindings/StringKeySearchResult.ts new file mode 100644 index 00000000..365e57ec --- /dev/null +++ b/src/lib/bindings/StringKeySearchResult.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { StringKeySuggestion } from "./StringKeySuggestion"; + +/** + * Result of a suggestion query. + */ +export type StringKeySearchResult = { + suggestions: Array; + /** + * Total number of known field names in the index. + */ + totalKeys: number; + /** + * Locale whose stringtable supplied the `value` previews, when available. + */ + locale: string | null; +}; diff --git a/src/lib/bindings/StringKeySuggestion.ts b/src/lib/bindings/StringKeySuggestion.ts new file mode 100644 index 00000000..5cbb0132 --- /dev/null +++ b/src/lib/bindings/StringKeySuggestion.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One autocomplete suggestion for a stringtable field. + */ +export type StringKeySuggestion = { + /** + * Field name, e.g. `game_character_displayname_ahri`. + */ + key: string; + /** + * What the game's stringtable currently says for this field in the + * indexed locale, when the key exists there. + */ + value: string | null; +}; diff --git a/src/lib/bindings/index.ts b/src/lib/bindings/index.ts index f84c9a01..b6a40be1 100644 --- a/src/lib/bindings/index.ts +++ b/src/lib/bindings/index.ts @@ -51,6 +51,8 @@ export type { SaveProjectConfigArgs } from "./SaveProjectConfigArgs"; export type { Settings } from "./Settings"; export type { Severity } from "./Severity"; export type { StorageMedium } from "./StorageMedium"; +export type { StringKeySearchResult } from "./StringKeySearchResult"; +export type { StringKeySuggestion } from "./StringKeySuggestion"; export type { Theme } from "./Theme"; export type { ValidationResult } from "./ValidationResult"; export type { WadBlocklistEntry } from "./WadBlocklistEntry"; diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 10a671f1..af275938 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -28,6 +28,7 @@ import type { SaveProjectConfigArgs, Settings, StorageMedium, + StringKeySearchResult, ValidationResult, WorkshopLayerInfo, WorkshopProject, @@ -227,6 +228,8 @@ export const api = { layerName, stringOverrides, }), + searchStringKeys: (query: string, limit?: number) => + invokeResult("search_string_keys", { query, limit }), getLayerContentPath: (projectPath: string, layerName: string) => invokeResult("get_layer_content_path", { projectPath, layerName }), getLayerInfo: (projectPath: string, layerNames: string[]) => diff --git a/src/modules/settings/components/AboutSection.tsx b/src/modules/settings/components/AboutSection.tsx index d1452d32..e9ea453e 100644 --- a/src/modules/settings/components/AboutSection.tsx +++ b/src/modules/settings/components/AboutSection.tsx @@ -1,6 +1,6 @@ import { FileText } from "lucide-react"; -import { Button, SectionCard } from "@/components"; +import { Button, ExternalLink, SectionCard } from "@/components"; import { api, type AppInfo } from "@/lib/tauri"; interface AboutSectionProps { @@ -32,22 +32,15 @@ export function AboutSection({ appInfo }: AboutSectionProps) { managing League of Legends mods using the modpkg format.

diff --git a/src/modules/settings/components/PatchingSection.tsx b/src/modules/settings/components/PatchingSection.tsx index 17e1f973..2b94ea15 100644 --- a/src/modules/settings/components/PatchingSection.tsx +++ b/src/modules/settings/components/PatchingSection.tsx @@ -46,6 +46,25 @@ export function PatchingSection({ settings, onSave }: PatchingSectionProps) { /> + +