diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/PlaceheldTextField.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/PlaceheldTextField.java index a6690fcd2..e428e190b 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/PlaceheldTextField.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/PlaceheldTextField.java @@ -1,7 +1,7 @@ package org.quiltmc.enigma.gui.element; import org.jspecify.annotations.Nullable; -import org.quiltmc.enigma.util.Utils; +import org.quiltmc.enigma.util.CollectionUtils; import javax.swing.JTextField; import javax.swing.text.Document; @@ -79,7 +79,7 @@ protected void paintComponent(Graphics graphics) { final Graphics localGraphics = graphics.create(); trySetRenderingHints(localGraphics); - Utils.findFirstNonNull(this.placeholderColor, this.getDisabledTextColor(), this.getForeground()) + CollectionUtils.findFirstNonNull(this.placeholderColor, this.getDisabledTextColor(), this.getForeground()) .ifPresent(localGraphics::setColor); localGraphics.setFont(this.getFont()); diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenusMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenusMenu.java index 0936d36cf..f5feaffd0 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenusMenu.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenusMenu.java @@ -53,7 +53,7 @@ import static org.quiltmc.enigma.gui.util.GuiUtil.getCenteredFontBaseY; import static org.quiltmc.enigma.gui.util.GuiUtil.trySetRenderingHints; import static org.quiltmc.enigma.util.StringLookup.toStringLookup; -import static org.quiltmc.enigma.util.Utils.getLastOrNull; +import static org.quiltmc.enigma.util.CollectionUtils.getLastOrNull; import static javax.swing.BorderFactory.createEmptyBorder; public class SearchMenusMenu extends AbstractEnigmaMenu { diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/MarkableScrollPane.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/MarkableScrollPane.java index 8a4a37ef7..cf66337fb 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/MarkableScrollPane.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/MarkableScrollPane.java @@ -4,12 +4,10 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.Multimap; import com.google.common.collect.Multimaps; -import com.google.common.collect.Multiset; -import com.google.common.collect.TreeMultiset; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.quiltmc.enigma.gui.util.GuiUtil; import org.quiltmc.enigma.gui.util.ScaleUtil; +import org.quiltmc.enigma.util.SortedCollection; import java.awt.Color; import java.awt.Component; @@ -22,6 +20,7 @@ import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -55,7 +54,10 @@ public class MarkableScrollPane extends SmartScrollPane { private static final int DEFAULT_MARKER_WIDTH = 10; private static final int DEFAULT_MARKER_HEIGHT = 5; - private final Multimap markersByPos = Multimaps.newMultimap(new HashMap<>(), TreeMultiset::create); + private final Multimap markersByPos = Multimaps.newMultimap( + new HashMap<>(), + () -> new SortedCollection<>(Marker.COMPARATOR, Marker::castOrNull) + ); private final int markerWidth; private final int markerHeight; @@ -514,11 +516,12 @@ Rectangle createArea() { } } - private record Marker(Color color, int priority, int pos, Optional listener) - implements Comparable { - @Override - public int compareTo(@NonNull Marker other) { - return other.priority - this.priority; + private record Marker(Color color, int priority, int pos, Optional listener) { + static Comparator COMPARATOR = Comparator.comparingInt(Marker::priority).reversed(); + + @Nullable + static Marker castOrNull(Object o) { + return o instanceof Marker marker ? marker : null; } class Span { @@ -557,7 +560,7 @@ private class MarkersPainterBuilder { final int pos; final int scaledPos; - final Multiset markers = TreeMultiset.create(); + final SortedCollection markers = new SortedCollection<>(Marker.COMPARATOR, Marker::castOrNull); int top; int bottom; diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/CollectionUtils.java b/enigma/src/main/java/org/quiltmc/enigma/util/CollectionUtils.java new file mode 100644 index 000000000..cc35dbfae --- /dev/null +++ b/enigma/src/main/java/org/quiltmc/enigma/util/CollectionUtils.java @@ -0,0 +1,87 @@ +package org.quiltmc.enigma.util; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.Optional; +import java.util.Set; + +/** + * Utilities for working with {@link Collection}s and arrays. + */ +public final class CollectionUtils { + private CollectionUtils() { + throw new UnsupportedOperationException(); + } + + @SafeVarargs + public static Optional findFirstNonNull(T... values) { + for (final T value : values) { + if (value != null) { + return Optional.of(value); + } + } + + return Optional.empty(); + } + + /** + * @return {@code null} if the passed {@code array} is {@code null} or empty, + * or the last element of the {@code array} otherwise + */ + @Nullable + public static T getLastOrNull(@Nullable T[] array) { + return array == null || array.length == 0 ? null : array[array.length - 1]; + } + + public static Set createIdentityHashSet() { + return Collections.newSetFromMap(new IdentityHashMap<>()); + } + + public static Set createIdentityHashSet(int expectedMaxSize) { + return Collections.newSetFromMap(new IdentityHashMap<>(expectedMaxSize)); + } + + /** + * A common implementation of {@link Collection#toArray()}. + */ + public static Object @NonNull [] toArrayImpl(Collection collection) { + final int size = collection.size(); + final Object[] array = new Object[size]; + final Iterator itr = collection.iterator(); + for (int i = 0; i < size; i++) { + array[i] = itr.next(); + } + + return array; + } + + /** + * A common implementation of {@link Collection#toArray(Object[])}. + * + *

Based on {@link HashMap}{@code ::prepareArray} and {@link HashMap}{@code ::keysToArray}. + */ + @SuppressWarnings("unchecked") + public static T1 @NonNull[] toArrayImpl(Collection collection, T1[] a) { + final int size = collection.size(); + if (a.length < size) { + a = (T1[]) java.lang.reflect.Array + .newInstance(a.getClass().getComponentType(), size); + } else if (a.length > size) { + a[size] = null; + } + + final Iterator itr = collection.iterator(); + final Object[] objects = a; + for (int i = 0; i < size; i++) { + objects[i] = itr.next(); + } + + return a; + } +} diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/CombinedCollection.java b/enigma/src/main/java/org/quiltmc/enigma/util/CombinedCollection.java index 3554c01ae..f71882eab 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/util/CombinedCollection.java +++ b/enigma/src/main/java/org/quiltmc/enigma/util/CombinedCollection.java @@ -51,36 +51,12 @@ public Iterator iterator() { @Override public Object @NonNull[] toArray() { - final int size = this.size(); - final Object[] array = new Object[size]; - final Iterator itr = this.iterator(); - int i = 0; - while (itr.hasNext()) { - array[i++] = itr.next(); - } - - return array; + return CollectionUtils.toArrayImpl(this); } - // Based on HashMap::prepareArray and HashMap::keysToArray - @SuppressWarnings("unchecked") @Override - public T1 @NonNull[] toArray(T1[] a) { - final int size = this.size(); - if (a.length < size) { - a = (T1[]) java.lang.reflect.Array - .newInstance(a.getClass().getComponentType(), size); - } else if (a.length > size) { - a[size] = null; - } - - final Iterator itr = this.iterator(); - final Object[] objects = a; - for (int i = 0; i < size; i++) { - objects[i] = itr.next(); - } - - return a; + public T1 @NonNull[] toArray(T1 @NonNull[] a) { + return CollectionUtils.toArrayImpl(this, a); } @Override diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/SortedCollection.java b/enigma/src/main/java/org/quiltmc/enigma/util/SortedCollection.java new file mode 100644 index 000000000..3dc01b7b8 --- /dev/null +++ b/enigma/src/main/java/org/quiltmc/enigma/util/SortedCollection.java @@ -0,0 +1,215 @@ +package org.quiltmc.enigma.util; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.function.Function; + +/** + * A collection that keeps its elements sorted. + * + *

Element order is determined by the {@link Comparator} passed when creating the collection.
+ * Insertion order is maintained for elements whose + * {@linkplain Comparator#compare(Object, Object) comparisons} are {@code 0}. + * + * @implNote {@code null} elements are not supported + * + * @param the type of elements + */ +public class SortedCollection implements Collection { + private final ArrayList delegate = new ArrayList<>(); + + private final Comparator comparator; + private final Function castOrNull; + + private int size; + + public SortedCollection(Comparator comparator, Function castOrNull) { + this.comparator = comparator; + this.castOrNull = castOrNull; + } + + @Override + public int size() { + return this.size; + } + + @Override + public boolean isEmpty() { + return this.size == 0; + } + + @Override + public boolean contains(Object o) { + final T element = this.castOrNull.apply(o); + + if (element == null) { + return false; + } + + final int found = Collections.binarySearch(this.delegate, new Equivalents(element)); + return found >= 0 && this.delegate.get(found).elements.stream().anyMatch(e -> e.equals(element)); + } + + @Override + @NonNull + public Iterator iterator() { + return new Itr(); + } + + @Override + public Object @NonNull[] toArray() { + return CollectionUtils.toArrayImpl(this); + } + + @Override + public T1 @NonNull[] toArray(T1 @NonNull[] a) { + return CollectionUtils.toArrayImpl(this, a); + } + + @Override + public boolean add(T element) { + Utils.requireNonNull(element, "element"); + + final Equivalents newEquivalents = new Equivalents(element); + final int found = Collections.binarySearch(this.delegate, newEquivalents); + if (found < 0) { + final int dest = -found - 1; + this.delegate.add(dest, newEquivalents); + } else { + this.delegate.get(found).elements.add(element); + } + + this.size++; + return true; + } + + @Override + public boolean remove(Object o) { + final T element = this.castOrNull.apply(o); + + if (element == null) { + return false; + } + + final int found = Collections.binarySearch(this.delegate, new Equivalents(element)); + if (found >= 0) { + final List elements = this.delegate.get(found).elements; + if (elements.remove(element)) { + // remove empty equivalents + if (elements.isEmpty()) { + this.delegate.remove(found); + } + + this.size--; + return true; + } + } + + return false; + } + + @Override + public boolean containsAll(@NonNull Collection collection) { + for (final Object o : collection) { + if (!this.contains(o)) { + return false; + } + } + + return true; + } + + @Override + public boolean addAll(@NonNull Collection collection) { + this.delegate.ensureCapacity(this.size + collection.size()); + + for (final T element : collection) { + this.add(element); + } + + return true; + } + + @Override + public boolean removeAll(@NonNull Collection collection) { + boolean removed = false; + for (final Object o : collection) { + removed |= this.remove(o); + } + + return removed; + } + + @Override + public boolean retainAll(@NonNull Collection collection) { + return this.removeIf(element -> !collection.contains(element)); + } + + @Override + public void clear() { + this.delegate.clear(); + this.size = 0; + } + + private final class Equivalents implements Comparable { + final List elements; + + private Equivalents(T element) { + this.elements = new LinkedList<>(); + this.elements.add(element); + } + + @Override + public int compareTo(@NonNull Equivalents other) { + return SortedCollection.this.comparator + .compare(this.elements.get(0), other.elements.get(0)); + } + } + + private class Itr implements Iterator { + final Iterator outerIterator = SortedCollection.this.delegate.iterator(); + @Nullable + InnerState innerState; + + @Override + public boolean hasNext() { + return this.outerIterator.hasNext() || this.innerState != null && this.innerState.iterator.hasNext(); + } + + @Override + public T next() { + if (this.innerState == null || !this.innerState.iterator.hasNext()) { + this.innerState = InnerState.of(this.outerIterator.next().elements); + } + + return this.innerState.iterator.next(); + } + + @Override + public void remove() { + if (this.innerState == null) { + throw new IllegalStateException("remove called before any call to next"); + } + + this.innerState.iterator.remove(); + SortedCollection.this.size--; + if (this.innerState.equivalents.isEmpty()) { + this.outerIterator.remove(); + } + } + + record InnerState(List equivalents, Iterator iterator) { + static InnerState of(List equivalents) { + return new InnerState<>(equivalents, equivalents.iterator()); + } + } + } +} diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/StringLookup.java b/enigma/src/main/java/org/quiltmc/enigma/util/StringLookup.java index 1b9808ac9..61123cbc3 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/util/StringLookup.java +++ b/enigma/src/main/java/org/quiltmc/enigma/util/StringLookup.java @@ -104,7 +104,7 @@ public static StringLookup of( // Use identity equality semantics so substrings can point to multiple results with the same target. // Duplicates must be present here because returned results may not match String::contains. final CompositeStringMultiTrie> substringBuilder = - CompositeStringMultiTrie.createHashedBranching(Utils::createIdentityHashSet); + CompositeStringMultiTrie.createHashedBranching(CollectionUtils::createIdentityHashSet); results.forEach(result -> { final String string = result.lookupString(); diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java index 45ec0d72a..3cb69ec8a 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java +++ b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java @@ -2,7 +2,6 @@ import com.google.common.io.CharStreams; import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -15,12 +14,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; -import java.util.IdentityHashMap; import java.util.List; import java.util.Locale; -import java.util.Optional; import java.util.Properties; -import java.util.Set; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; @@ -210,34 +206,6 @@ public static T requireNonNull(T value, String name) { } } - @SafeVarargs - public static Optional findFirstNonNull(T... values) { - for (final T value : values) { - if (value != null) { - return Optional.of(value); - } - } - - return Optional.empty(); - } - - /** - * @return {@code null} if the passed {@code array} is {@code null} or empty, - * or the last element of the {@code array} otherwise - */ - @Nullable - public static T getLastOrNull(@Nullable T[] array) { - return array == null || array.length == 0 ? null : array[array.length - 1]; - } - - public static Set createIdentityHashSet() { - return Collections.newSetFromMap(new IdentityHashMap<>()); - } - - public static Set createIdentityHashSet(int expectedMaxSize) { - return Collections.newSetFromMap(new IdentityHashMap<>(expectedMaxSize)); - } - @SafeVarargs public static Stream lazyConcat(Supplier>... streamers) { return Stream.of(streamers).flatMap(Supplier::get); diff --git a/enigma/src/test/java/org/quiltmc/enigma/util/SortedCollectionTest.java b/enigma/src/test/java/org/quiltmc/enigma/util/SortedCollectionTest.java new file mode 100644 index 000000000..3e37ef629 --- /dev/null +++ b/enigma/src/test/java/org/quiltmc/enigma/util/SortedCollectionTest.java @@ -0,0 +1,104 @@ +package org.quiltmc.enigma.util; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Streams; +import org.hamcrest.Matchers; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import java.util.Comparator; +import java.util.Iterator; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SortedCollectionTest { + private static final ImmutableList VALUES = ImmutableList.of( + 1, 5, 3, 3, 7, 5, 4, 8, 9, 0, 3, 2 + ); + + private static SortedCollection createSortedInts() { + return new SortedCollection<>( + Integer::compare, + o -> o instanceof Integer integer ? integer : null + ); + } + + @Test + void sorts() { + final SortedCollection sorted = createSortedInts(); + + for (int i = 0; i < VALUES.size(); i++) { + sorted.add(VALUES.get(i)); + final Integer[] expectation = VALUES.subList(0, i + 1) + .stream() + .sorted() + .toArray(Integer[]::new); + + assertThat(sorted, Matchers.contains(expectation)); + } + } + + @Test + void maintainsInsertionOrder() { + final SortedCollection sorted = new SortedCollection<>( + InsertedValue.VALUE_COMPARATOR, + InsertedValue::castOrNull + ); + + for (int i = 0; i < VALUES.size(); i++) { + sorted.add(new InsertedValue(VALUES.get(i), i)); + + @SuppressWarnings("DataFlowIssue") + final InsertedValue[] expectation = Streams + .mapWithIndex( + VALUES.subList(0, i + 1).stream(), + (value, index) -> new InsertedValue(value, (int) index) + ) + .sorted(InsertedValue.EXPECTATION_COMPARATOR) + .toArray(InsertedValue[]::new); + + assertThat(sorted, Matchers.contains(expectation)); + } + } + + @Test + void remove() { + final SortedCollection sorted = createSortedInts(); + sorted.addAll(VALUES); + + int expectedSize = sorted.size(); + for (final int value : VALUES) { + assertTrue(sorted.remove(value)); + + assertEquals(--expectedSize, sorted.size()); + } + } + + @Test + void iteratorRemove() { + final SortedCollection sorted = createSortedInts(); + sorted.addAll(VALUES); + + final Iterator itr = sorted.iterator(); + int expectedSize = sorted.size(); + while (itr.hasNext()) { + itr.next(); + itr.remove(); + + assertEquals(--expectedSize, sorted.size()); + } + } + + private record InsertedValue(int value, int inserted) { + static final Comparator VALUE_COMPARATOR = Comparator.comparingInt(InsertedValue::value); + static final Comparator EXPECTATION_COMPARATOR = VALUE_COMPARATOR + .thenComparingInt(InsertedValue::inserted); + + @Nullable + static InsertedValue castOrNull(Object o) { + return o instanceof InsertedValue insertedValue ? insertedValue : null; + } + } +}