From d6a23e0fca93ef977b808bedc0cf8320c0760de9 Mon Sep 17 00:00:00 2001 From: Vinicius Mangueira Date: Mon, 24 Aug 2026 18:22:21 -0300 Subject: [PATCH 1/8] feat(ui): add asset catalog visibility filters Context: The vehicle catalog can contain many base-game, content-pack, and custom assets. Text search alone does not let users isolate the assets that are currently forbidden or allowed. Changes: - add localized All, Forbidden, and Allowed visibility options - combine visibility filtering with text search and target transport compatibility - keep matching child assets associated with their parent group - reset pagination when the visibility option changes - replace repeated parent lookup scans with a set of relevant asset IDs Behavior: The catalog previously displayed every compatible asset that matched the text search. Users can now change which matching entries are visible without modifying the pending forbidden selection or the restriction stored on the selected target. Groups are expanded while a visibility filter is active so matching children remain discoverable. Compatibility: - no save-format changes - no pathfinding, vehicle movement, or ECS changes - no dependency or package changes - English and Simplified Chinese labels are included Security: - no network access - no filesystem access outside the repository - no executable, deployment, or publishing changes Validation: - git diff --check: passed - localization key/reference review: passed - static review of All, Forbidden, Allowed, search composition, target transport filtering, grouping, and pagination reset: passed - UI build: not performed because authorization was not granted - C# build: not performed because authorization was not granted - runtime test in Cities: Skylines II: not performed --- Setting.cs | 4 ++++ UI/src/routeFilterUI.module.scss | 4 ++++ UI/src/routeFilterUI.tsx | 40 +++++++++++++++++++++++++------- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Setting.cs b/Setting.cs index 9012c85..5169aa9 100644 --- a/Setting.cs +++ b/Setting.cs @@ -76,6 +76,10 @@ public IEnumerable> ReadEntries(IList span { flex: 0 0 auto; margin-right: 6rem; opacity: .75; } +.catalogControls select { flex: 1 1 auto; min-width: 0; height: 28rem; padding: 0 7rem; border: 1rem solid rgba(255,255,255,.18); border-radius: 6rem; color: white; background: #26333b; } .details { flex: 0 0 58rem; margin-top: 7rem; padding: 7rem 9rem; border-radius: 6rem; background: rgba(0,0,0,.2); font-size: 12rem; overflow: hidden; } .details div { display: flex; align-items: center; margin-bottom: 5rem; } .details span { display: inline-block; margin-right: 12rem; opacity: .82; } diff --git a/UI/src/routeFilterUI.tsx b/UI/src/routeFilterUI.tsx index 3a93f2b..77bec27 100644 --- a/UI/src/routeFilterUI.tsx +++ b/UI/src/routeFilterUI.tsx @@ -13,6 +13,8 @@ type VehicleAsset = { braking: number; parentId: number; trailer: boolean; }; +type AssetVisibility = "all" | "forbidden" | "allowed"; + const toolActive$ = bindValue(mod.id, "toolActive", false); const targetMode$ = bindValue(mod.id, "targetMode", 0); const targetTransport$ = bindValue(mod.id, "targetTransport", 0); @@ -31,6 +33,13 @@ const parseCatalog = (raw: string): VehicleAsset[] => raw.split("\n").reduce) => { + if (search && !asset.name.toLocaleLowerCase().includes(search)) return false; + if (visibility === "forbidden") return selected.has(asset.id); + if (visibility === "allowed") return !selected.has(asset.id); + return true; +}; + const AssetGlyph = ({ mode, trailer }: { mode: number; trailer: boolean }) => {mode === 2 ? <> @@ -45,6 +54,7 @@ const RefreshGlyph = () => { const [search, setSearch] = useState(""); + const [visibility, setVisibility] = useState("all"); const [expanded, setExpanded] = useState>(() => new Set()); const [hovered, setHovered] = useState(null); const [page, setPage] = useState(0); @@ -62,22 +72,28 @@ export const RouteFilterUI = () => { const selected = useMemo(() => new Set(selectedRaw.split(",").map(Number).filter(Number.isInteger)), [selectedRaw]); const normalizedSearch = search.trim().toLocaleLowerCase(); const relevant = useMemo(() => assets.filter(asset => targetTransport === 0 || (asset.mode & targetTransport) !== 0), [assets, targetTransport]); + const relevantIds = useMemo(() => new Set(relevant.map(asset => asset.id)), [relevant]); const children = useMemo(() => { const map = new Map(); - relevant.forEach(asset => { if (asset.parentId) map.set(asset.parentId, [...(map.get(asset.parentId) ?? []), asset]); }); + relevant.forEach(asset => { + if (!asset.parentId) return; + const siblings = map.get(asset.parentId); + if (siblings) siblings.push(asset); else map.set(asset.parentId, [asset]); + }); return map; }, [relevant]); - const roots = useMemo(() => relevant.filter(asset => !asset.parentId || !relevant.some(candidate => candidate.id === asset.parentId)).filter(asset => { - if (!normalizedSearch) return true; - return asset.name.toLocaleLowerCase().includes(normalizedSearch) || (children.get(asset.id) ?? []).some(child => child.name.toLocaleLowerCase().includes(normalizedSearch)); - }), [relevant, children, normalizedSearch]); + const roots = useMemo(() => relevant + .filter(asset => !asset.parentId || !relevantIds.has(asset.parentId)) + .filter(asset => matchesCatalogFilters(asset, normalizedSearch, visibility, selected) || + (children.get(asset.id) ?? []).some(child => matchesCatalogFilters(child, normalizedSearch, visibility, selected))), + [relevant, relevantIds, children, normalizedSearch, visibility, selected]); const selectedRelevant = relevant.filter(asset => selected.has(asset.id)).length; const pageSize = 30; const pageCount = Math.max(1, Math.ceil(roots.length / pageSize)); const pageIndex = Math.min(page, pageCount - 1); const visibleRoots = roots.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); - useEffect(() => setPage(0), [search, targetTransport]); + useEffect(() => setPage(0), [search, visibility, targetTransport]); useEffect(() => { if (!open) return; return () => { trigger(mod.id, "setPointerOverUi", false); }; @@ -92,7 +108,8 @@ export const RouteFilterUI = () => { const renderAsset = (asset: VehicleAsset, child = false) => { const childAssets = children.get(asset.id) ?? []; - const isExpanded = expanded.has(asset.id) || normalizedSearch.length > 0; + const visibleChildAssets = childAssets.filter(item => matchesCatalogFilters(item, normalizedSearch, visibility, selected)); + const isExpanded = expanded.has(asset.id) || normalizedSearch.length > 0 || visibility !== "all"; const groupIds = [asset.id, ...childAssets.map(item => item.id)]; const selectedCount = groupIds.filter(id => selected.has(id)).length; const partial = childAssets.length > 0 && selectedCount > 0 && selectedCount < groupIds.length; @@ -110,7 +127,7 @@ export const RouteFilterUI = () => { {childAssets.length > 0 && {childAssets.length + 1}} - {childAssets.length > 0 && isExpanded && childAssets.filter(item => !normalizedSearch || item.name.toLocaleLowerCase().includes(normalizedSearch)).map(item => renderAsset(item, true))} + {childAssets.length > 0 && isExpanded && visibleChildAssets.map(item => renderAsset(item, true))} ; }; @@ -131,6 +148,13 @@ export const RouteFilterUI = () => {
{targetLabel}{selectedRelevant} / {relevant.length} {tr("RouteFilter.UI.ForbiddenCount", "forbidden")}
setSearch(event.target.value)} placeholder={tr("RouteFilter.UI.Search", "Search vehicle assets")} /> +
+ +
{hovered ? <>
{hovered.name}
{tr("RouteFilter.UI.MaxSpeed", "Maximum speed")}: {hovered.maxSpeed} km/h From 7b6b2e6d17fd3308d2306a50a48c330536215414 Mon Sep 17 00:00:00 2001 From: Vinicius Mangueira Date: Mon, 24 Aug 2026 18:24:03 -0300 Subject: [PATCH 2/8] feat(ui): add asset catalog sorting controls Context: The catalog exposes vehicle performance data but previously kept a fixed backend name order. Users could not rank compatible assets by maximum speed, acceleration, or braking before reviewing a page. Changes: - add localized Name, Maximum speed, Acceleration, and Braking sort choices - add an accessible ascending and descending direction control - sort filtered roots before pagination with deterministic name and ID tie-breakers - keep child assets attached to their parent and order children alphabetically - reset pagination whenever the sort field or direction changes Behavior: Catalog roots can now be ordered locally by name or numeric vehicle metrics in either direction. Pagination consumes the sorted roots, while grouped trailers and carriages remain nested beneath their original parent in predictable alphabetical order. Compatibility: - no backend catalog order changes - no save-format, pathfinding, vehicle movement, or ECS changes - no dependency or package changes - English and Simplified Chinese labels are included Security: - no network access - no filesystem access outside the repository - no executable, deployment, or publishing changes Validation: - git diff --check: passed - localization key/reference review: passed - static review of name and numeric comparators, ascending and descending direction, deterministic ties, root-only sorting, grouping, and pre-pagination ordering: passed - UI build: not performed because authorization was not granted - C# build: not performed because authorization was not granted - runtime test in Cities: Skylines II: not performed --- Setting.cs | 4 +++ UI/src/routeFilterUI.module.scss | 4 ++- UI/src/routeFilterUI.tsx | 42 +++++++++++++++++++++++++++----- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/Setting.cs b/Setting.cs index 5169aa9..3ef718b 100644 --- a/Setting.cs +++ b/Setting.cs @@ -80,6 +80,10 @@ public IEnumerable> ReadEntries(IList span { flex: 0 0 auto; margin-right: 6rem; opacity: .75; } .catalogControls select { flex: 1 1 auto; min-width: 0; height: 28rem; padding: 0 7rem; border: 1rem solid rgba(255,255,255,.18); border-radius: 6rem; color: white; background: #26333b; } +.catalogControls label + label { margin-left: 7rem; } +.catalogControls .sortDirection { flex: 0 0 30rem; width: 30rem; height: 28rem; margin-left: 7rem; font-size: 16rem; } .details { flex: 0 0 58rem; margin-top: 7rem; padding: 7rem 9rem; border-radius: 6rem; background: rgba(0,0,0,.2); font-size: 12rem; overflow: hidden; } .details div { display: flex; align-items: center; margin-bottom: 5rem; } .details span { display: inline-block; margin-right: 12rem; opacity: .82; } diff --git a/UI/src/routeFilterUI.tsx b/UI/src/routeFilterUI.tsx index 77bec27..5ad8001 100644 --- a/UI/src/routeFilterUI.tsx +++ b/UI/src/routeFilterUI.tsx @@ -14,6 +14,8 @@ type VehicleAsset = { }; type AssetVisibility = "all" | "forbidden" | "allowed"; +type AssetSort = "name" | "maxSpeed" | "acceleration" | "braking"; +type SortDirection = "ascending" | "descending"; const toolActive$ = bindValue(mod.id, "toolActive", false); const targetMode$ = bindValue(mod.id, "targetMode", 0); @@ -40,6 +42,18 @@ const matchesCatalogFilters = (asset: VehicleAsset, search: string, visibility: return true; }; +const compareNames = (left: VehicleAsset, right: VehicleAsset) => + left.name.toLocaleLowerCase().localeCompare(right.name.toLocaleLowerCase()) || left.id - right.id; + +const compareAssets = (left: VehicleAsset, right: VehicleAsset, sort: AssetSort) => { + if (sort === "name") return compareNames(left, right); + const difference = left[sort] - right[sort]; + return difference || compareNames(left, right); +}; + +const sortAssets = (assets: VehicleAsset[], sort: AssetSort, direction: SortDirection) => + [...assets].sort((left, right) => compareAssets(left, right, sort) * (direction === "ascending" ? 1 : -1)); + const AssetGlyph = ({ mode, trailer }: { mode: number; trailer: boolean }) => {mode === 2 ? <> @@ -55,6 +69,8 @@ const RefreshGlyph = () => { const [search, setSearch] = useState(""); const [visibility, setVisibility] = useState("all"); + const [sort, setSort] = useState("name"); + const [sortDirection, setSortDirection] = useState("ascending"); const [expanded, setExpanded] = useState>(() => new Set()); const [hovered, setHovered] = useState(null); const [page, setPage] = useState(0); @@ -80,20 +96,22 @@ export const RouteFilterUI = () => { const siblings = map.get(asset.parentId); if (siblings) siblings.push(asset); else map.set(asset.parentId, [asset]); }); + map.forEach(siblings => siblings.sort(compareNames)); return map; }, [relevant]); - const roots = useMemo(() => relevant - .filter(asset => !asset.parentId || !relevantIds.has(asset.parentId)) - .filter(asset => matchesCatalogFilters(asset, normalizedSearch, visibility, selected) || - (children.get(asset.id) ?? []).some(child => matchesCatalogFilters(child, normalizedSearch, visibility, selected))), - [relevant, relevantIds, children, normalizedSearch, visibility, selected]); + const roots = useMemo(() => sortAssets(relevant + .filter(asset => !asset.parentId || !relevantIds.has(asset.parentId)) + .filter(asset => matchesCatalogFilters(asset, normalizedSearch, visibility, selected) || + (children.get(asset.id) ?? []).some(child => matchesCatalogFilters(child, normalizedSearch, visibility, selected))), + sort, sortDirection), + [relevant, relevantIds, children, normalizedSearch, visibility, selected, sort, sortDirection]); const selectedRelevant = relevant.filter(asset => selected.has(asset.id)).length; const pageSize = 30; const pageCount = Math.max(1, Math.ceil(roots.length / pageSize)); const pageIndex = Math.min(page, pageCount - 1); const visibleRoots = roots.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); - useEffect(() => setPage(0), [search, visibility, targetTransport]); + useEffect(() => setPage(0), [search, visibility, sort, sortDirection, targetTransport]); useEffect(() => { if (!open) return; return () => { trigger(mod.id, "setPointerOverUi", false); }; @@ -154,6 +172,18 @@ export const RouteFilterUI = () => { + +
{hovered ? <>
{hovered.name}
From 1776eb9d5486d92fbfd85ca58e22acb669677220 Mon Sep 17 00:00:00 2001 From: Vinicius Mangueira Date: Mon, 24 Aug 2026 18:26:11 -0300 Subject: [PATCH 3/8] feat(ui): track and revert pending restriction changes Context: The panel already keeps asset edits pending until Apply, but it does not indicate when those edits differ from the restriction stored on the selected target and offers no way to discard them without reselecting the target. Changes: - snapshot the applied asset entities whenever a target is selected - expose an order-independent pendingChanges binding based on HashSet.SetEquals - refresh the applied snapshot after Apply and after catalog membership changes - update the applied baseline after Clear without overwriting pending edits - add a Revert pending changes trigger that restores only the panel selection - show a compact localized pending status and revert action Behavior: Toggling assets now marks the selected target as having pending changes whenever the pending and applied sets differ. Manually restoring the same set or using Revert clears the indicator. Apply reloads the effective stored restriction, Clear makes the applied baseline empty while preserving distinct pending edits, and selecting another target rebuilds both sets for that target. Compatibility: - no save-format or persistence changes - no pathfinding, vehicle movement, or restriction component changes - existing Apply, Clear, and target-selection semantics remain distinct - no dependency or package changes - English and Simplified Chinese labels are included Security: - no network access - no filesystem access outside the repository - no executable, deployment, or publishing changes Validation: - git diff --check: passed - binding, trigger, and localization reference review: passed - static control-flow review of no target, empty and populated restrictions, edit/manual undo, Revert, Apply, target switching, and Clear: passed - order-independent set comparison review: passed - UI build: not performed because authorization was not granted - C# build: not performed because authorization was not granted - runtime test in Cities: Skylines II: not performed --- Setting.cs | 2 ++ Systems/RouteFilterUISystem.cs | 47 ++++++++++++++++++++++++++++++-- UI/src/routeFilterUI.module.scss | 5 +++- UI/src/routeFilterUI.tsx | 6 ++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/Setting.cs b/Setting.cs index 3ef718b..24e9d5f 100644 --- a/Setting.cs +++ b/Setting.cs @@ -102,6 +102,8 @@ public IEnumerable> ReadEntries(IList m_IdsByAsset = new(); private readonly Dictionary m_ModeByAsset = new(); private readonly Dictionary> m_ChildrenByAsset = new(); + private readonly HashSet m_AppliedVehicleAssets = new(); private ValueBinding m_ToolActiveBinding = null!; private ValueBinding m_TargetModeBinding = null!; private ValueBinding m_TargetTransportBinding = null!; private ValueBinding m_SelectedTargetKindBinding = null!; private ValueBinding m_AssetCatalogBinding = null!; private ValueBinding m_SelectedAssetsBinding = null!; + private ValueBinding m_PendingChangesBinding = null!; private Entity m_LastSelectedTarget = Entity.Null; private int m_LastQueryOrderVersion = int.MinValue; private int m_LastVehicleOrderVersion = int.MinValue; @@ -67,6 +69,7 @@ protected override void OnCreate() m_SelectedTargetKindBinding = CreateValue("selectedTargetKind", 0); m_AssetCatalogBinding = CreateValue("assetCatalog", string.Empty); m_SelectedAssetsBinding = CreateValue("selectedAssetIds", string.Empty); + m_PendingChangesBinding = CreateValue("pendingChanges", false); AddBinding(new TriggerBinding(Mod.Id, "toggleTool", ToggleTool)); AddBinding(new TriggerBinding(Mod.Id, "toggleAsset", ToggleAsset)); @@ -75,8 +78,9 @@ protected override void OnCreate() AddBinding(new TriggerBinding(Mod.Id, "selectAllAssets", SelectAllAssets)); AddBinding(new TriggerBinding(Mod.Id, "selectNoAssets", SelectNoAssets)); AddBinding(new TriggerBinding(Mod.Id, "refreshAssets", RefreshAssetCatalog)); - AddBinding(new TriggerBinding(Mod.Id, "applySelection", m_RestrictionTool.ApplySelection)); - AddBinding(new TriggerBinding(Mod.Id, "clearSelectedRestriction", m_RestrictionTool.ClearSelectedRestriction)); + AddBinding(new TriggerBinding(Mod.Id, "revertPendingChanges", RevertPendingChanges)); + AddBinding(new TriggerBinding(Mod.Id, "applySelection", ApplySelection)); + AddBinding(new TriggerBinding(Mod.Id, "clearSelectedRestriction", ClearSelectedRestriction)); AddBinding(new TriggerBinding(Mod.Id, "cancelSelection", m_RestrictionTool.ClearSelection)); AddBinding(new TriggerBinding(Mod.Id, "setPointerOverUi", m_RestrictionTool.SetPointerOverUi)); @@ -257,6 +261,7 @@ private void RefreshAssetCatalog() } Mod.SelectedVehicleAssets.RemoveWhere(entity => !m_IdsByAsset.ContainsKey(entity)); + m_AppliedVehicleAssets.RemoveWhere(entity => !m_IdsByAsset.ContainsKey(entity)); m_AssetCatalogBinding.Update(string.Join("\n", lines)); UpdateSelectedBinding(); @@ -276,16 +281,45 @@ private void RefreshAssetCatalog() private void LoadSelectedTargetAssets(Entity target) { Mod.SelectedVehicleAssets.Clear(); + m_AppliedVehicleAssets.Clear(); if (target != Entity.Null && EntityManager.Exists(target) && EntityManager.TryGetBuffer(target, true, out DynamicBuffer assets)) { foreach (var asset in assets) if (asset.m_Prefab != Entity.Null && m_IdsByAsset.ContainsKey(asset.m_Prefab)) + { Mod.SelectedVehicleAssets.Add(asset.m_Prefab); + m_AppliedVehicleAssets.Add(asset.m_Prefab); + } } UpdateSelectedBinding(); } + private void RevertPendingChanges() + { + if (m_RestrictionTool.SelectedTarget == Entity.Null) return; + Mod.SelectedVehicleAssets.Clear(); + foreach (var asset in m_AppliedVehicleAssets) + if (m_IdsByAsset.ContainsKey(asset)) Mod.SelectedVehicleAssets.Add(asset); + UpdateSelectedBinding(); + } + + private void ApplySelection() + { + var target = m_RestrictionTool.SelectedTarget; + m_RestrictionTool.ApplySelection(); + if (target != Entity.Null) LoadSelectedTargetAssets(target); + else UpdatePendingChangesBinding(); + } + + private void ClearSelectedRestriction() + { + var target = m_RestrictionTool.SelectedTarget; + m_RestrictionTool.ClearSelectedRestriction(); + if (target != Entity.Null) m_AppliedVehicleAssets.Clear(); + UpdatePendingChangesBinding(); + } + private void ToggleAsset(int id) { if (!m_AssetsById.TryGetValue(id, out var entity)) { Mod.Log.Warn($"UI requested unknown asset id {id}"); return; } @@ -328,6 +362,13 @@ private void UpdateSelectedBinding() { m_SelectedAssetsBinding.Update(string.Join(",", Mod.SelectedVehicleAssets .Where(m_IdsByAsset.ContainsKey).Select(entity => m_IdsByAsset[entity]).OrderBy(id => id))); + UpdatePendingChangesBinding(); } -} + private void UpdatePendingChangesBinding() + { + var target = m_RestrictionTool.SelectedTarget; + m_PendingChangesBinding.Update(target != Entity.Null && EntityManager.Exists(target) && + !m_AppliedVehicleAssets.SetEquals(Mod.SelectedVehicleAssets)); + } +} diff --git a/UI/src/routeFilterUI.module.scss b/UI/src/routeFilterUI.module.scss index b0b7c66..f83b07c 100644 --- a/UI/src/routeFilterUI.module.scss +++ b/UI/src/routeFilterUI.module.scss @@ -19,7 +19,7 @@ .warning { flex: 0 0 auto; margin-top: 8rem; padding: 9rem 11rem; border-left: 3rem solid #ff5e42; background: rgba(255,94,66,.12); } .warning strong, .warning span { display: block; } .warning span { margin-top: 3rem; opacity: .82; font-size: 12rem; } -.actions button, .modes button, .expand, .pager button, .applyActions button, .catalogControls button { +.actions button, .modes button, .expand, .pager button, .applyActions button, .catalogControls button, .pendingChanges button { display: flex; align-items: center; justify-content: center; border: 0; border-radius: 6rem; color: white; background: rgba(255,255,255,.09); cursor: pointer; } @@ -69,6 +69,9 @@ .pager button { width: 42rem; height: 28rem; } .pager span { min-width: 150rem; text-align: center; font-size: 11rem; opacity: .78; } .pager button:disabled, .applyActions button:disabled { opacity: .35; } +.pendingChanges { display: flex; flex: 0 0 32rem; align-items: center; justify-content: space-between; margin-top: 5rem; padding: 0 7rem; border: 1rem solid rgba(202,125,35,.7); border-radius: 6rem; background: rgba(202,125,35,.16); } +.pendingChanges strong { font-size: 11rem; color: #ffd39a; } +.pendingChanges button { height: 24rem; padding: 0 7rem; font-size: 10rem; } .applyActions { display: flex; flex: 0 0 38rem; min-height: 38rem; margin-top: 7rem; } .applyActions button { flex: 1 1 auto; padding: 8rem 5rem; } .applyActions button + button { margin-left: 6rem; } diff --git a/UI/src/routeFilterUI.tsx b/UI/src/routeFilterUI.tsx index 5ad8001..cbc7f4e 100644 --- a/UI/src/routeFilterUI.tsx +++ b/UI/src/routeFilterUI.tsx @@ -23,6 +23,7 @@ const targetTransport$ = bindValue(mod.id, "targetTransport", 0); const selectedTargetKind$ = bindValue(mod.id, "selectedTargetKind", 0); const assetCatalog$ = bindValue(mod.id, "assetCatalog", ""); const selectedAssetIds$ = bindValue(mod.id, "selectedAssetIds", ""); +const pendingChanges$ = bindValue(mod.id, "pendingChanges", false); const parseCatalog = (raw: string): VehicleAsset[] => raw.split("\n").reduce((result, line) => { const part = line.split("|"); @@ -82,6 +83,7 @@ export const RouteFilterUI = () => { const selectedTargetKind = useValue(selectedTargetKind$); const catalogRaw = useValue(assetCatalog$); const selectedRaw = useValue(selectedAssetIds$); + const pendingChanges = useValue(pendingChanges$); const { translate } = useLocalization(); const tr = (key: string, fallback: string) => String(translate(key) ?? fallback); const assets = useMemo(() => parseCatalog(catalogRaw), [catalogRaw]); @@ -205,6 +207,10 @@ export const RouteFilterUI = () => { {pageIndex + 1} / {pageCount} · {selected.size} / {assets.length}
+ {selectedTargetKind !== 0 && pendingChanges &&
+ {tr("RouteFilter.UI.PendingChanges", "Pending changes")} + +
}
From c7f169c7fb880a6667962caadcc2ffad1fd6b8d7 Mon Sep 17 00:00:00 2001 From: Vinicius Mangueira Date: Mon, 24 Aug 2026 18:27:11 -0300 Subject: [PATCH 4/8] fix(localization): localize asset catalog accessibility labels Context: Asset toggle controls exposed English-only accessibility labels, and the existing refresh control referenced a localization key that was absent from the locale dictionary. Changes: - replace hardcoded Allow asset and Forbid asset aria labels with localized lookups - add English and Simplified Chinese labels for allowing and forbidding an asset - define the existing RefreshAssets localization key in both languages Behavior: Assistive labels for asset toggles and catalog refresh now follow the active supported language. Visible catalog behavior and restriction semantics are unchanged. Compatibility: - no save-format, gameplay, pathfinding, vehicle movement, or ECS changes - no dependency or package changes - existing localization fallback text remains available Security: - no network access - no filesystem access outside the repository - no executable, deployment, or publishing changes Validation: - git diff --check: passed - literal search confirmed each accessibility key has a locale entry and JSX reference - static localization review for English and Simplified Chinese: passed - UI build: not performed because authorization was not granted - C# build: not performed because authorization was not granted - runtime test in Cities: Skylines II: not performed --- Setting.cs | 3 +++ UI/src/routeFilterUI.tsx | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Setting.cs b/Setting.cs index 24e9d5f..c64dde2 100644 --- a/Setting.cs +++ b/Setting.cs @@ -98,6 +98,9 @@ public IEnumerable> ReadEntries(IList {
setHovered(asset)} onMouseOver={() => setHovered(asset)}> {childAssets.length > 0 ? : } -
- {selectedTargetKind !== 0 && pendingChanges &&
- {tr("RouteFilter.UI.PendingChanges", "Pending changes")} - -
}
From 5d50d6df3e262cd70c58a47c88e2fd37fb46abee Mon Sep 17 00:00:00 2001 From: Vinicius Mangueira Date: Mon, 24 Aug 2026 19:13:30 -0300 Subject: [PATCH 6/8] fix(ui): ignore empty selected asset identifiers Context: The selectedAssetIds binding uses a comma-separated string. Converting every split token directly with Number caused an empty binding to produce the numeric value 0 because Number of an empty string is zero. Changes: - trim each selected-asset token before conversion - discard empty tokens before calling Number - continue accepting only integer identifiers when constructing the selected Set Behavior: An empty selection now creates an empty Set instead of a Set containing 0. Normal lists such as 1,2,3 remain unchanged, repeated separators are ignored, and non-numeric or non-integer tokens do not enter the selection. Compatibility: - no save format or persistence changes - no pathfinding, gameplay, vehicle movement, or ECS changes - no dependency or version changes - valid existing selected asset identifiers retain their numeric values Security: - no network or filesystem behavior changes - no executable or credential handling changes - no deployment or publishing changes Validation: - empty string parsing: produced an empty array - 1,2,3 parsing: produced [1,2,3] - ,1,,2, parsing: produced [1,2] - x,1,2.5,3 parsing: produced [1,3] - full staged diff review: passed - git diff --check: passed - UI build: deferred until the complete branch validation - Cities: Skylines II runtime validation: not performed --- UI/src/routeFilterUI.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UI/src/routeFilterUI.tsx b/UI/src/routeFilterUI.tsx index 02dda41..43aa397 100644 --- a/UI/src/routeFilterUI.tsx +++ b/UI/src/routeFilterUI.tsx @@ -85,7 +85,7 @@ export const RouteFilterUI = () => { const { translate } = useLocalization(); const tr = (key: string, fallback: string) => String(translate(key) ?? fallback); const assets = useMemo(() => parseCatalog(catalogRaw), [catalogRaw]); - const selected = useMemo(() => new Set(selectedRaw.split(",").map(Number).filter(Number.isInteger)), [selectedRaw]); + const selected = useMemo(() => new Set(selectedRaw.split(",").map(value => value.trim()).filter(Boolean).map(Number).filter(Number.isInteger)), [selectedRaw]); const normalizedSearch = search.trim().toLocaleLowerCase(); const relevant = useMemo(() => assets.filter(asset => targetTransport === 0 || (asset.mode & targetTransport) !== 0), [assets, targetTransport]); const relevantIds = useMemo(() => new Set(relevant.map(asset => asset.id)), [relevant]); From 1ae1534671888a628a253e293a1d00149b0ef3b1 Mon Sep 17 00:00:00 2001 From: Vinicius Mangueira Date: Mon, 24 Aug 2026 19:16:08 -0300 Subject: [PATCH 7/8] fix(ui): preserve visibility semantics for grouped assets Context: Catalog roots are retained when a matching child needs its parent for identification. The previous rendering treated that retained parent as a normal filter result, which could make an allowed parent look like a Forbidden result or a non-matching search result look directly selectable. Filtered groups were also forced open in a way that prevented the expand control from collapsing them. Changes: - identify roots that fail the active search/visibility criteria but contain a matching child as context-only rows - render context-only roots with neutral styling and a fixed spacer instead of the restriction toggle - continue rendering only children that satisfy both search and visibility criteria - track collapse overrides while filtering so automatically expanded matching groups can still be collapsed and reopened - reset filtered collapse overrides when search, visibility, or target transport changes Behavior: A root that matches the active criteria is rendered normally. A non-matching root is retained only to identify matching children, cannot change restrictions from its context row, and uses subdued styling. Parent-child relationships, root sorting, pre-pagination filtering, and child name ordering remain intact. Search and Allowed/Forbidden filters continue to combine, and filtered groups retain working expand/collapse controls. Compatibility: - no save format or persistence changes - no pathfinding, gameplay, vehicle movement, or ECS changes - no asset IDs or selected-state data are changed by filtering - no dependency or version changes Security: - no network or filesystem behavior changes - no executable or credential handling changes - no deployment or publishing changes Validation: - parent allowed with forbidden child: parent retained as context and only matching child returned - matching forbidden parent: parent rendered as a normal result - Allowed filter with forbidden child: non-matching child omitted - search for Carriage B under Train A: parent retained as context and child returned - filtered group collapse and re-expand simulation: passed - full staged TSX and SCSS diff review: passed - git diff --check: passed - UI build: deferred until the complete branch validation - Cities: Skylines II runtime validation: not performed --- UI/src/routeFilterUI.module.scss | 2 ++ UI/src/routeFilterUI.tsx | 28 +++++++++++++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/UI/src/routeFilterUI.module.scss b/UI/src/routeFilterUI.module.scss index b0b7c66..280ccdc 100644 --- a/UI/src/routeFilterUI.module.scss +++ b/UI/src/routeFilterUI.module.scss @@ -53,8 +53,10 @@ .assetRow.child { margin-left: 24rem; width: auto; } .assetRow.forbidden { background-color: rgba(190, 58, 40, .78); } .assetRow.partial { background-color: rgba(202, 125, 35, .62); } +.assetRow.contextOnly { opacity: .72; background-color: rgba(255,255,255,.035); } .expand, .expandSpacer { flex: 0 0 25rem; width: 25rem; margin-right: 5rem; } .expand { font-size: 20rem; line-height: 30rem; } +.checkSpacer { flex: 0 0 20rem; width: 20rem; height: 20rem; } .check { flex: 0 0 20rem; display: flex; align-items: center; justify-content: center; width: 20rem; height: 20rem; padding: 0; border: 1rem solid #a9b3b9; border-radius: 10rem; color: white; background-color: rgba(0,0,0,.2); } .checked { border-color: white; background-color: #a93428; } .partialCheck { background-color: #a96a23; } diff --git a/UI/src/routeFilterUI.tsx b/UI/src/routeFilterUI.tsx index 43aa397..014b775 100644 --- a/UI/src/routeFilterUI.tsx +++ b/UI/src/routeFilterUI.tsx @@ -72,6 +72,7 @@ export const RouteFilterUI = () => { const [sort, setSort] = useState("name"); const [sortDirection, setSortDirection] = useState("ascending"); const [expanded, setExpanded] = useState>(() => new Set()); + const [collapsedWhileFiltering, setCollapsedWhileFiltering] = useState>(() => new Set()); const [hovered, setHovered] = useState(null); const [page, setPage] = useState(0); const anchor = useRef(null); @@ -110,38 +111,43 @@ export const RouteFilterUI = () => { const pageCount = Math.max(1, Math.ceil(roots.length / pageSize)); const pageIndex = Math.min(page, pageCount - 1); const visibleRoots = roots.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); + const filteringCatalog = normalizedSearch.length > 0 || visibility !== "all"; useEffect(() => setPage(0), [search, visibility, sort, sortDirection, targetTransport]); + useEffect(() => setCollapsedWhileFiltering(new Set()), [search, visibility, targetTransport]); useEffect(() => { if (!open) return; return () => { trigger(mod.id, "setPointerOverUi", false); }; }, [open]); const togglePanel = () => trigger(mod.id, "toggleTool"); - const toggleExpanded = (id: number) => setExpanded(current => { - const next = new Set(current); - if (next.has(id)) next.delete(id); else next.add(id); - return next; - }); + const toggleExpanded = (id: number) => { + const update = (current: Set) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }; + if (filteringCatalog) setCollapsedWhileFiltering(update); else setExpanded(update); + }; - const renderAsset = (asset: VehicleAsset, child = false) => { + const renderAsset = (asset: VehicleAsset, child = false, contextOnly = false) => { const childAssets = children.get(asset.id) ?? []; const visibleChildAssets = childAssets.filter(item => matchesCatalogFilters(item, normalizedSearch, visibility, selected)); - const isExpanded = expanded.has(asset.id) || normalizedSearch.length > 0 || visibility !== "all"; + const isExpanded = filteringCatalog ? !collapsedWhileFiltering.has(asset.id) : expanded.has(asset.id); const groupIds = [asset.id, ...childAssets.map(item => item.id)]; const selectedCount = groupIds.filter(id => selected.has(id)).length; const partial = childAssets.length > 0 && selectedCount > 0 && selectedCount < groupIds.length; return -
setHovered(asset)} onMouseOver={() => setHovered(asset)}> {childAssets.length > 0 ? : } - + }
{asset.name} {childAssets.length > 0 && {childAssets.length + 1}} @@ -199,7 +205,7 @@ export const RouteFilterUI = () => {
{ event.currentTarget.scrollTop += event.deltaY; event.stopPropagation(); }} onMouseLeave={() => setHovered(null)}> - {visibleRoots.map(asset => renderAsset(asset))} + {visibleRoots.map(asset => renderAsset(asset, false, !matchesCatalogFilters(asset, normalizedSearch, visibility, selected)))} {roots.length === 0 &&

{tr("RouteFilter.UI.Empty", "No matching vehicle assets")}

}
From e318ff6a04b480b7991f8e83202cceb0c5243cbe Mon Sep 17 00:00:00 2001 From: Vinicius Mangueira Date: Mon, 24 Aug 2026 19:19:50 -0300 Subject: [PATCH 8/8] chore(ui): restore original UI system formatting Context: Deferring the pending-change workflow should leave RouteFilterUISystem identical to its pre-feature implementation. Removing the deferred methods also removed an existing terminal blank line, leaving a formatting-only backend diff in the branch. Changes: - restore the original end-of-file spacing in RouteFilterUISystem.cs - eliminate the remaining backend file difference from main Behavior: There is no runtime or UI behavior change. The backend UI system content now matches the main branch exactly after the pending-change workflow removal. Compatibility: - no save format, persistence, pathfinding, gameplay, vehicle movement, or ECS changes - no dependency, version, or localization changes Security: - no network or filesystem behavior changes - no executable or credential handling changes - no deployment or publishing changes Validation: - full staged diff review: passed - git diff main -- Systems/RouteFilterUISystem.cs with the staged content: no differences - git diff --check main: passed - C# build: not performed because Cities: Skylines II assemblies/toolchain are unavailable - Cities: Skylines II runtime validation: not performed --- Systems/RouteFilterUISystem.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Systems/RouteFilterUISystem.cs b/Systems/RouteFilterUISystem.cs index 351cc70..342bfd1 100644 --- a/Systems/RouteFilterUISystem.cs +++ b/Systems/RouteFilterUISystem.cs @@ -330,3 +330,4 @@ private void UpdateSelectedBinding() .Where(m_IdsByAsset.ContainsKey).Select(entity => m_IdsByAsset[entity]).OrderBy(id => id))); } } +