diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/MappedObservableList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/MappedObservableList.java index cdc2de1c4..25642c0de 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/MappedObservableList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/MappedObservableList.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,124 +17,121 @@ */ package org.jackhuang.hmcl.util.javafx; -import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; -import javafx.collections.WeakListChangeListener; -import org.jackhuang.hmcl.util.Holder; +import javafx.collections.transformation.TransformationList; +import org.jetbrains.annotations.NotNull; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; import java.util.function.Function; -import static java.util.stream.Collectors.toCollection; -import static javafx.collections.FXCollections.unmodifiableObservableList; +/// @author Glavo +public final class MappedObservableList extends TransformationList { -/** - * @author yushijinhun - */ -public final class MappedObservableList { - private MappedObservableList() { + /// This method creates a mapping of `source`, using `mapper` as the converter. + /// + /// If an item is added to `source`, `mapper` will be invoked to create a corresponding item, which will also be added to the returned `ObservableList`. + /// If an item is removed from `source`, the corresponding item in the returned `ObservableList` will also be removed. + /// If `source` is permutated, the returned `ObservableList` will also be permutated in the same way. + /// + /// The returned `ObservableList` is unmodifiable. + public static ObservableList create(ObservableList source, Function mapper) { + return new MappedObservableList<>(source, mapper); } - private static final class MappedObservableListUpdater implements ListChangeListener { - private final ObservableList origin; - private final ObservableList target; - private final Function mapper; + private final Function mapper; + private final List elements; - // If we directly synchronize changes to target, each operation on target will cause a event to be fired. - // So we first write changes to buffer. After all the changes are processed, we use target.setAll to synchronize the changes. - private final List buffer; - - MappedObservableListUpdater(ObservableList origin, ObservableList target, Function mapper) { - this.origin = origin; - this.target = target; - this.mapper = mapper; - this.buffer = new ArrayList<>(target); + public MappedObservableList(@NotNull ObservableList source, @NotNull Function mapper) { + super(source); + this.mapper = mapper; + this.elements = new ArrayList<>(source.size()); + for (F f : source) { + elements.add(mapper.apply(f)); } + } - @Override - public void onChanged(Change change) { - // cache removed elements to reduce calls to mapper - Map> cache = new IdentityHashMap<>(); + @Override + @SuppressWarnings("unchecked") + protected void sourceChanged(ListChangeListener.Change change) { + beginChange(); + while (change.next()) { + int from = change.getFrom(); + int to = change.getTo(); - while (change.next()) { - int from = change.getFrom(); - int to = change.getTo(); + if (change.wasPermutated()) { + Object[] temp = new Object[to - from]; + int[] permutations = new int[to - from]; - if (change.wasPermutated()) { - @SuppressWarnings("unchecked") - U[] temp = (U[]) new Object[to - from]; - for (int i = 0; i < temp.length; i++) { - temp[i] = buffer.get(from + i); - } + for (int i = 0; i < temp.length; i++) { + temp[i] = elements.get(from + i); + } - for (int idx = from; idx < to; idx++) { - buffer.set(change.getPermutation(idx), temp[idx - from]); - } - } else { - if (change.wasRemoved()) { - List originRemoved = change.getRemoved(); - List targetRemoved = buffer.subList(from, from + originRemoved.size()); - for (int i = 0; i < targetRemoved.size(); i++) { - pushCache(cache, originRemoved.get(i), targetRemoved.get(i)); - } - targetRemoved.clear(); - } - if (change.wasAdded()) { - @SuppressWarnings("unchecked") - U[] toAdd = (U[]) new Object[to - from]; - for (int i = 0; i < toAdd.length; i++) { - toAdd[i] = map(cache, origin.get(from + i)); - } - buffer.addAll(from, Arrays.asList(toAdd)); + for (int i = from; i < to; i++) { + int n = i - from; + int permutation = change.getPermutation(i); + permutations[n] = permutation; + elements.set(permutation, (E) temp[n]); + } + + nextPermutation(from, to, permutations); + } else if (change.wasUpdated()) { + for (int i = from; i < to; i++) { + elements.set(i, mapper.apply(getSource().get(i))); + nextUpdate(i); + } + } else { + List removed = List.of(); + if (change.wasRemoved()) { + List subList = elements.subList(from, from + change.getRemovedSize()); + removed = new ArrayList<>(subList); + subList.clear(); + } + + if (change.wasAdded()) { + Object[] temp = new Object[to - from]; + List addedSubList = change.getAddedSubList(); + for (int i = 0; i < addedSubList.size(); i++) { + temp[i] = mapper.apply(addedSubList.get(i)); } + elements.addAll(from, (List) Arrays.asList(temp)); + } + + if (change.wasRemoved() && change.wasAdded()) { + nextReplace(from, to, removed); + } else if (change.wasRemoved()) { + nextRemove(from, removed); + } else if (change.wasAdded()) { + nextAdd(from, to); } } - target.setAll(buffer); - } - - private void pushCache(Map> cache, T key, U value) { - cache.computeIfAbsent(key, any -> new LinkedList<>()) - .push(value); - } - - private U map(Map> cache, T key) { - LinkedList stack = cache.get(key); - if (stack != null && !stack.isEmpty()) { - return stack.pop(); - } - return mapper.apply(key); } + endChange(); } - /** - * This methods creates a mapping of {@code origin}, using {@code mapper} as the converter. - * - * If an item is added to {@code origin}, {@code mapper} will be invoked to create a corresponding item, which will also be added to the returned {@code ObservableList}. - * If an item is removed from {@code origin}, the corresponding item in the returned {@code ObservableList} will also be removed. - * If {@code origin} is permutated, the returned {@code ObservableList} will also be permutated in the same way. - * - * The returned {@code ObservableList} is unmodifiable. - */ - public static ObservableList create(ObservableList origin, Function mapper) { - // create a already-synchronized target ObservableList - ObservableList target = origin.stream() - .map(mapper) - .collect(toCollection(FXCollections::observableArrayList)); - - // then synchronize further changes to target - ListChangeListener listener = new MappedObservableListUpdater<>(origin, target, mapper); - - // let target hold a reference to listener to prevent listener being garbage-collected before target is garbage-collected - target.addListener(new Holder<>(listener)); - - // let origin hold a weak reference to listener, so that target can be garbage-collected when it's no longer used - origin.addListener(new WeakListChangeListener<>(listener)); - - // ref graph: - // target ------> listener <-weak- origin - // <------ ------> - - return unmodifiableObservableList(target); + @Override + public E get(int index) { + return elements.get(index); } + + @Override + public int size() { + return elements.size(); + } + + @Override + public int getSourceIndex(int index) { + Objects.checkIndex(index, this.size()); + return index; + } + + @Override + public int getViewIndex(int index) { + Objects.checkIndex(index, this.size()); + return index; + } + } diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/javafx/MappedObservableListTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/javafx/MappedObservableListTest.java new file mode 100644 index 000000000..6165a1d94 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/javafx/MappedObservableListTest.java @@ -0,0 +1,266 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.javafx; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.ObservableListBase; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.*; + +/// @author Glavo +public class MappedObservableListTest { + + @Test + public void testInitialMapping() { + ObservableList source = FXCollections.observableArrayList(1, 2, 3, 4, 5); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + assertEquals(5, mapped.size()); + assertEquals("Item-1", mapped.get(0)); + assertEquals("Item-2", mapped.get(1)); + assertEquals("Item-3", mapped.get(2)); + assertEquals("Item-4", mapped.get(3)); + assertEquals("Item-5", mapped.get(4)); + } + + @Test + public void testAdd() { + ObservableList source = FXCollections.observableArrayList(1, 2, 3); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + source.add(4); + assertEquals(4, mapped.size()); + assertEquals("Item-4", mapped.get(3)); + + source.add(1, 10); + assertEquals(5, mapped.size()); + assertEquals("Item-10", mapped.get(1)); + assertEquals("Item-2", mapped.get(2)); + } + + @Test + public void testRemove() { + ObservableList source = FXCollections.observableArrayList(1, 2, 3, 4, 5); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + source.remove(2); + assertEquals(4, mapped.size()); + assertEquals("Item-1", mapped.get(0)); + assertEquals("Item-2", mapped.get(1)); + assertEquals("Item-4", mapped.get(2)); + assertEquals("Item-5", mapped.get(3)); + + source.remove(Integer.valueOf(1)); + assertEquals(3, mapped.size()); + assertEquals("Item-2", mapped.get(0)); + assertEquals("Item-4", mapped.get(1)); + assertEquals("Item-5", mapped.get(2)); + + source.remove(1, 3); + assertEquals(1, mapped.size()); + assertEquals("Item-2", mapped.get(0)); + } + + @Test + public void testSet() { + ObservableList source = FXCollections.observableArrayList(1, 2, 3, 4, 5); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + source.set(2, 10); + assertEquals(5, mapped.size()); + + // Verify the actual values + assertEquals("Item-1", mapped.get(0)); + assertEquals("Item-2", mapped.get(1)); + assertEquals("Item-10", mapped.get(2)); + assertEquals("Item-4", mapped.get(3)); + assertEquals("Item-5", mapped.get(4)); + } + + @Test + public void testSort() { + ObservableList source = FXCollections.observableArrayList(5, 3, 1, 4, 2); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + FXCollections.sort(source); + + assertEquals(5, mapped.size()); + assertEquals("Item-1", mapped.get(0)); + assertEquals("Item-2", mapped.get(1)); + assertEquals("Item-3", mapped.get(2)); + assertEquals("Item-4", mapped.get(3)); + assertEquals("Item-5", mapped.get(4)); + } + + @Test + public void testClear() { + ObservableList source = FXCollections.observableArrayList(1, 2, 3, 4, 5); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + source.clear(); + + assertEquals(0, mapped.size()); + assertTrue(mapped.isEmpty()); + assertThrows(IndexOutOfBoundsException.class, () -> mapped.get(0)); + } + + @Test + public void testAddAll() { + ObservableList source = FXCollections.observableArrayList(1, 2); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + source.addAll(3, 4, 5); + + assertEquals(5, mapped.size()); + assertEquals("Item-3", mapped.get(2)); + assertEquals("Item-4", mapped.get(3)); + assertEquals("Item-5", mapped.get(4)); + } + + @Test + public void testRemoveAll() { + ObservableList source = FXCollections.observableArrayList(1, 2, 3, 4, 5); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + source.removeAll(2, 4); + + assertEquals(3, mapped.size()); + assertEquals("Item-1", mapped.get(0)); + assertEquals("Item-3", mapped.get(1)); + assertEquals("Item-5", mapped.get(2)); + } + + @Test + public void testSetAll() { + ObservableList source = FXCollections.observableArrayList(1, 2, 3); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + source.setAll(10, 20, 30, 40); + + assertEquals(4, mapped.size()); + assertEquals("Item-10", mapped.get(0)); + assertEquals("Item-20", mapped.get(1)); + assertEquals("Item-30", mapped.get(2)); + assertEquals("Item-40", mapped.get(3)); + } + + @Test + public void testGetSourceIndex() { + ObservableList source = FXCollections.observableArrayList(1, 2, 3, 4, 5); + MappedObservableList mapped = new MappedObservableList<>(source, i -> "Item-" + i); + + assertEquals(0, mapped.getSourceIndex(0)); + assertEquals(2, mapped.getSourceIndex(2)); + assertEquals(4, mapped.getSourceIndex(4)); + + assertThrows(IndexOutOfBoundsException.class, () -> mapped.getSourceIndex(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> mapped.getSourceIndex(5)); + } + + @Test + public void testGetViewIndex() { + ObservableList source = FXCollections.observableArrayList(1, 2, 3, 4, 5); + MappedObservableList mapped = new MappedObservableList<>(source, i -> "Item-" + i); + + assertEquals(0, mapped.getViewIndex(0)); + assertEquals(2, mapped.getViewIndex(2)); + assertEquals(4, mapped.getViewIndex(4)); + + assertThrows(IndexOutOfBoundsException.class, () -> mapped.getViewIndex(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> mapped.getViewIndex(5)); + } + + @Test + public void testComplexOperations() { + ObservableList source = FXCollections.observableArrayList(); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + // Start with empty + assertEquals(0, mapped.size()); + + // Add some elements + source.addAll(1, 2, 3, 4, 5); + assertEquals(5, mapped.size()); + + // Remove middle element + source.remove(2); + assertEquals(4, mapped.size()); + assertEquals("Item-4", mapped.get(2)); + + // Sort + FXCollections.sort(source, Collections.reverseOrder()); + assertEquals("Item-5", mapped.get(0)); + assertEquals("Item-4", mapped.get(1)); + assertEquals("Item-2", mapped.get(2)); + assertEquals("Item-1", mapped.get(3)); + + // Add at specific position + source.add(2, 3); + assertEquals(5, mapped.size()); + assertEquals("Item-3", mapped.get(2)); + } + + /// Test for [javafx.collections.ListChangeListener.Change#wasUpdated()]. + @Test + public void testUpdate() { + class TestUpdateList extends ObservableListBase { + private final List backingList; + + @SafeVarargs + public TestUpdateList(T... items) { + this.backingList = Arrays.asList(items); + } + + @Override + public int size() { + return backingList.size(); + } + + @Override + public T get(int index) { + return backingList.get(index); + } + + public void updateItem(int beginIndex, int endIndex, Function mapper) { + Objects.checkFromToIndex(beginIndex, endIndex, size()); + beginChange(); + for (int i = beginIndex; i < endIndex; i++) { + backingList.set(i, mapper.apply(backingList.get(i))); + nextUpdate(i); + } + endChange(); + } + } + + TestUpdateList source = new TestUpdateList<>(1, 2, 3, 4, 5); + ObservableList mapped = MappedObservableList.create(source, i -> "Item-" + i); + + source.updateItem(2, 4, i -> i * 10); + assertEquals(5, mapped.size()); + assertEquals("Item-1", mapped.get(0)); + assertEquals("Item-2", mapped.get(1)); + assertEquals("Item-30", mapped.get(2)); + assertEquals("Item-40", mapped.get(3)); + assertEquals("Item-5", mapped.get(4)); + } +}