33import javax .annotation .Nullable ;
44import java .util .*;
55import java .util .function .Function ;
6+ import java .util .function .Predicate ;
67import java .util .regex .Pattern ;
78import java .util .stream .Collectors ;
89
@@ -38,6 +39,15 @@ public class IngredientFilter implements IIngredientFilter, IIngredientGridSourc
3839 private List <IIngredientListElement > ingredientListCached = Collections .emptyList ();
3940 private List <Object > collapsedListCached = Collections .emptyList ();
4041 @ Nullable private String filterCached ;
42+ /**
43+ * Cached sorted list of all currently-visible ingredients — the result of a full
44+ * suffix-tree traversal + sort. This does NOT change when the search-bar text changes,
45+ * only when ingredients are added/removed or their visibility changes. Caching it here
46+ * avoids the expensive {@code elementSearch.getAllIngredients()} traversal on every
47+ * keystroke (once in {@link #getIngredientListUncached} for empty filter and once more
48+ * in {@link #withGroupNameMatches} for every non-empty filter).
49+ */
50+ @ Nullable private List <IIngredientListElement <?>> allVisibleIngredientsCache = null ;
4151
4252 private boolean afterBlock = false ;
4353 @ Nullable private List <Runnable > delegatedActions ;
@@ -56,13 +66,13 @@ public void logStatistics() {
5666 public void addIngredients (NonNullList <IIngredientListElement > ingredients ) {
5767 ingredients .sort (IngredientListElementComparator .INSTANCE );
5868 this .elementSearch .addAll (ingredients );
59- this . filterCached = null ;
69+ invalidateCache () ;
6070 }
6171
6272 public <V > void addIngredient (IIngredientListElement <V > element ) {
6373 updateHiddenState (element );
6474 this .elementSearch .add (element );
65- this . filterCached = null ;
75+ invalidateCache () ;
6676 }
6777
6878 public void delegateAfterBlock (Runnable runnable ) {
@@ -97,6 +107,23 @@ public void block() {
97107
98108 public void invalidateCache () {
99109 this .filterCached = null ;
110+ this .allVisibleIngredientsCache = null ;
111+ }
112+
113+ /**
114+ * Returns a cached, sorted list of every currently-visible ingredient.
115+ * The cache is invalidated whenever {@link #invalidateCache()} is called (ingredient
116+ * additions, visibility changes, mode changes), but NOT on search-text changes — the
117+ * full ingredient set is independent of the search bar content.
118+ */
119+ private List <IIngredientListElement <?>> getAllVisibleIngredients () {
120+ if (allVisibleIngredientsCache == null ) {
121+ allVisibleIngredientsCache = this .elementSearch .getAllIngredients ().stream ()
122+ .filter (IIngredientListElement ::isVisible )
123+ .sorted (IngredientListElementComparator .INSTANCE )
124+ .collect (Collectors .toList ());
125+ }
126+ return allVisibleIngredientsCache ;
100127 }
101128
102129 public <V > List <IIngredientListElement <V >> findMatchingElements (IIngredientListElement <V > element ) {
@@ -140,7 +167,7 @@ public void modesChanged() {
140167
141168 @ SubscribeEvent
142169 public void onEditModeToggleEvent (EditModeToggleEvent event ) {
143- this . filterCached = null ;
170+ invalidateCache () ;
144171 updateHidden ();
145172
146173 // In Hide Ingredients Mode the user cannot Alt+Click to expand/collapse groups,
@@ -171,7 +198,7 @@ public <V> void updateHiddenState(IIngredientListElement<V> element) {
171198 (Config .isEditModeEnabled () || !Config .isIngredientOnConfigBlacklist (ingredient , ingredientHelper ));
172199 if (element .isVisible () != visible ) {
173200 element .setVisible (visible );
174- this . filterCached = null ;
201+ invalidateCache () ;
175202 }
176203 }
177204
@@ -245,20 +272,14 @@ public void setFilterText(String filterText) {
245272
246273 private List <IIngredientListElement <?>> getIngredientListUncached (String filterText ) {
247274 if (filterText .isEmpty ()) {
248- return this .elementSearch .getAllIngredients ().stream ()
249- .filter (IIngredientListElement ::isVisible )
250- .sorted (IngredientListElementComparator .INSTANCE )
251- .collect (Collectors .toList ());
275+ return new ArrayList <>(getAllVisibleIngredients ());
252276 }
253277 List <SearchToken > tokens = Arrays .stream (filterText .split ("\\ |" ))
254278 .map (SearchToken ::parseSearchToken )
255279 .filter (s -> !s .search .isEmpty ())
256280 .collect (Collectors .toList ());
257281 if (tokens .isEmpty ()) {
258- return this .elementSearch .getAllIngredients ().stream ()
259- .filter (IIngredientListElement ::isVisible )
260- .sorted (IngredientListElementComparator .INSTANCE )
261- .collect (Collectors .toList ());
282+ return new ArrayList <>(getAllVisibleIngredients ());
262283 }
263284 return tokens .stream ()
264285 .map (token -> token .getSearchResults (this .elementSearch ))
@@ -295,8 +316,9 @@ private List<IIngredientListElement<?>> withGroupNameMatches(
295316 Set <IIngredientListElement <?>> seen = Collections .newSetFromMap (new IdentityHashMap <>());
296317 seen .addAll (baseList );
297318 List <IIngredientListElement <?>> result = new ArrayList <>(baseList );
298- for (IIngredientListElement <?> element : this .elementSearch .getAllIngredients ()) {
299- if (!element .isVisible () || seen .contains (element )) {
319+ // getAllVisibleIngredients() is cached — no extra suffix-tree traversal per keystroke.
320+ for (IIngredientListElement <?> element : getAllVisibleIngredients ()) {
321+ if (seen .contains (element )) {
300322 continue ;
301323 }
302324 for (CollapsedStack entry : matchingGroups ) {
@@ -353,10 +375,36 @@ private List<Object> collapse(List<IIngredientListElement> ingredientList) {
353375 // Track which entries have already been added to the result list
354376 Set <CollapsedStack > addedToResult = Collections .newSetFromMap (new IdentityHashMap <>());
355377
378+ // Precompute per-element UIDs if any active entry has a UID-based matcher.
379+ // This reduces getUniqueIdentifierForStack() calls from O(n×m) to O(n) for
380+ // custom groups (which are the expensive ones in the profiler).
381+ boolean hasUidEntries = false ;
382+ for (CollapsedStack entry : activeEntries ) {
383+ if (entry .getUidMatcher () != null ) {
384+ hasUidEntries = true ;
385+ break ;
386+ }
387+ }
388+ final String [] elementUids ;
389+ if (hasUidEntries ) {
390+ elementUids = new String [ingredientList .size ()];
391+ for (int i = 0 ; i < ingredientList .size (); i ++) {
392+ elementUids [i ] = CollapsedStack .computeIngredientUid (ingredientList .get (i ).getIngredient ());
393+ }
394+ } else {
395+ elementUids = null ;
396+ }
397+
398+ int idx = 0 ;
356399 for (IIngredientListElement <?> element : ingredientList ) {
400+ final String cachedUid = elementUids != null ? elementUids [idx ] : null ;
357401 boolean matched = false ;
358402 for (CollapsedStack entry : activeEntries ) {
359- if (entry .matches (element )) {
403+ Predicate <String > uidMatcher = entry .getUidMatcher ();
404+ boolean isMatch = (uidMatcher != null && cachedUid != null )
405+ ? uidMatcher .test (cachedUid )
406+ : entry .matches (element );
407+ if (isMatch ) {
360408 if (addedToResult .add (entry )) {
361409 result .add (entry );
362410 }
@@ -367,6 +415,7 @@ private List<Object> collapse(List<IIngredientListElement> ingredientList) {
367415 if (!matched ) {
368416 result .add (element );
369417 }
418+ idx ++;
370419 }
371420
372421 // Remove empty collapsed stacks (shouldn't happen, but be safe)
0 commit comments