From 01ece2008d595e820fe99023c733f6a49b15b7a0 Mon Sep 17 00:00:00 2001 From: Tobias Diez Date: Tue, 5 May 2020 18:50:29 +0200 Subject: [PATCH] Concat lists (#6) * Add EasyBind.concat(List>) Creates a new list that combines the values of the given lists. Unlike FXCollections.concat(), updates to the source lists propagate to the combined list. * Fix compiler errors * add some tests for concatenating lists * Fix bugs in the ConcatList implementation * Duplicated List values will break the implementation's change events * Added better tests that test different combinations. This commit closes comments in #8. * Add .idea to .gitignore, clean up test case * Added support for an "ObservableList" input * Adds an ObservableList input * Tracks changes to the List, firing changes if the 2D list changes, this eases up the work when bigger changes happen * Change `concat` -> `flattenList`, it's a flatten operation after all * Rename flatten to concat Co-authored-by: maul.esel Co-authored-by: Kevin Brightwell --- .../java/org/fxmisc/easybind/EasyBind.java | 12 + .../org/fxmisc/easybind/FlattenedList.java | 181 +++++++++++++++ .../fxmisc/easybind/FlattenedListTest.java | 208 ++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 src/main/java/org/fxmisc/easybind/FlattenedList.java create mode 100644 src/test/java/org/fxmisc/easybind/FlattenedListTest.java diff --git a/src/main/java/org/fxmisc/easybind/EasyBind.java b/src/main/java/org/fxmisc/easybind/EasyBind.java index a98ac46..d3b7184 100644 --- a/src/main/java/org/fxmisc/easybind/EasyBind.java +++ b/src/main/java/org/fxmisc/easybind/EasyBind.java @@ -11,6 +11,7 @@ import javafx.beans.property.Property; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; @@ -116,6 +117,17 @@ public static ObservableList map( return new MappedList<>(sourceList, f); } + public static ObservableList flatten( + ObservableList> sources) { + return new FlattenedList<>(sources); + } + + @SafeVarargs + public static ObservableList concat( + ObservableList... sources) { + return new FlattenedList<>(FXCollections.observableArrayList(sources)); + } + /** * Creates a new list in which each element is converted using the provided mapping. * All changes to the underlying list are propagated to the converted list. diff --git a/src/main/java/org/fxmisc/easybind/FlattenedList.java b/src/main/java/org/fxmisc/easybind/FlattenedList.java new file mode 100644 index 0000000..83bf164 --- /dev/null +++ b/src/main/java/org/fxmisc/easybind/FlattenedList.java @@ -0,0 +1,181 @@ +package org.fxmisc.easybind; + +import java.util.*; +import java.util.function.Consumer; + +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.ObservableListBase; + +class FlattenedList extends ObservableListBase { + private final ObservableList> sourceLists; + + FlattenedList(ObservableList> sourceLists) { + if (sourceLists == null) { + throw new NullPointerException("sourceLists = null"); + } + + this.sourceLists = sourceLists; + + // We make a Unique set of source lists, otherwise the event gets called multiple + // times if there are duplicate lists. + Set> sourcesSet = new HashSet<>(sourceLists); + sourcesSet.forEach(source -> source.addListener(this::onSourceChanged)); + + sourceLists.addListener(this::onSourcesListChanged); + } + + private void onSourcesListChanged(ListChangeListener.Change> change) { + + beginChange(); + + while (change.next()) { + int fromIdx = 0; // Flattened start idx + for (int i = 0; i < change.getFrom(); ++i) { + fromIdx += sourceLists.get(i).size(); + } + + int toIdx = fromIdx; // Flattened end idx + for (int i = change.getFrom(); i < change.getTo(); ++i) { + toIdx += sourceLists.get(i).size(); + } + + final int rangeSize = toIdx - fromIdx; + + if (change.wasPermutated()) { + + // build up a set of permutations based on the offsets AND the actual permutation + int[] permutation = new int[rangeSize]; + int fIdx = fromIdx; + for (int parentIdx = change.getFrom(); parentIdx < change.getTo(); ++parentIdx) { + for (int i = 0; i < sourceLists.get(i).size(); ++i, fIdx++) { + permutation[fIdx] = change.getPermutation(parentIdx) + i; + } + } + + nextPermutation(fromIdx, toIdx, permutation); + } else if (change.wasUpdated()) { + // Just iterate over the fromIdx..toIdx + for (int i = fromIdx; i < toIdx; ++i) { + nextUpdate(i); + } + } else if (change.wasAdded()) { + nextAdd(fromIdx, toIdx); + } else { + // Each remove is indexed + List itemsToRemove = new ArrayList<>(rangeSize); + + change.getRemoved().forEach(itemsToRemove::addAll); + + nextRemove(fromIdx, itemsToRemove); + } + } + + endChange(); + } + + private void onSourceChanged(ListChangeListener.Change change) { + ObservableList source = change.getList(); + + List offsets = new ArrayList<>(); + int calcOffset = 0; + for (ObservableList currList : sourceLists) { + if (currList == source) { + offsets.add(calcOffset); + } + + calcOffset += currList.size(); + } + + // Because a List could be duplicated, we have to do the change for EVERY offset. + // Annoying, but it's needed. + + beginChange(); + while (change.next()) { + if (change.wasPermutated()) { + int rangeSize = change.getTo() - change.getFrom(); + + // build up a set of permutations based on the offsets AND the actual permutation + int[] permutation = new int[rangeSize * offsets.size()]; + for (int offsetIdx = 0; offsetIdx < offsets.size(); ++offsetIdx) { + int indexOffset = offsets.get(offsetIdx); + for (int i = 0; i < rangeSize; ++i) { + permutation[i + offsetIdx * rangeSize] = + change.getPermutation(i + change.getFrom()) + indexOffset; + } + } + + for (int indexOffset: offsets) { + nextPermutation(change.getFrom() + indexOffset, change.getTo() + indexOffset, permutation); + } + } else if (change.wasUpdated()) { + + // For each update, it's just the index from getFrom()..getTo() + indexOffset + for (int indexOffset: offsets) { + for (int i = change.getFrom(); i < change.getTo(); ++i) { + nextUpdate(i + indexOffset); + } + } + } else if (change.wasAdded()) { + + // Each Add is just from() + the offset + for (int indexOffset: offsets) { + nextAdd(change.getFrom() + indexOffset, change.getTo() + indexOffset); + } + + } else { + // Each remove is indexed + for (int indexOffset: offsets) { + nextRemove(change.getFrom() + indexOffset, change.getRemoved()); + } + } + } + endChange(); + } + + @Override + public E get(int index) { + if (index < 0) + throw new IndexOutOfBoundsException("List index must be >= 0. Was " + index); + + for (ObservableList source : sourceLists) { + if (index < source.size()) + return source.get(index); + index -= source.size(); + } + throw new IndexOutOfBoundsException("Index too large."); + } + + @Override + public Iterator iterator() { + return new Iterator() { + Iterator> sourceIterator = sourceLists.iterator(); + Iterator currentIterator = null; + + @Override + public boolean hasNext() { + while (currentIterator == null || !currentIterator.hasNext()) + if (sourceIterator.hasNext()) + currentIterator = sourceIterator.next().iterator(); + else + return false; + return true; + } + + @Override + public E next() { + while (currentIterator == null || !currentIterator.hasNext()) + if (sourceIterator.hasNext()) + currentIterator = sourceIterator.next().iterator(); + else + throw new NoSuchElementException(); + return currentIterator.next(); + } + }; + } + + @Override + public int size() { + return sourceLists.stream().mapToInt(ObservableList::size).sum(); + } +} diff --git a/src/test/java/org/fxmisc/easybind/FlattenedListTest.java b/src/test/java/org/fxmisc/easybind/FlattenedListTest.java new file mode 100644 index 0000000..3c2fe37 --- /dev/null +++ b/src/test/java/org/fxmisc/easybind/FlattenedListTest.java @@ -0,0 +1,208 @@ +package org.fxmisc.easybind; + +import java.util.Arrays; +import java.util.List; + +import javafx.beans.binding.Bindings; +import javafx.beans.binding.StringBinding; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +public class FlattenedListTest { + + abstract class CountedChangeListener implements ListChangeListener { + + private int callCount = 0; + + @Override + public void onChanged(Change c) { + callCount++; + actualChange(c); + } + + int getCallCount() { + return callCount; + } + + public abstract void actualChange(Change c); + } + + private ObservableList a; + private ObservableList b; + private ObservableList c; + private ObservableList d; + + private ObservableList> aa; + + private StringBinding bindCOne; + private StringBinding bindCFour; + + @BeforeEach + public void setup() { + a = FXCollections.observableArrayList("zero", "one", "two"); + b = FXCollections.observableArrayList("three", "four", "five"); + c = EasyBind.concat(a, b); + + aa = FXCollections.observableArrayList(a, a); + d = EasyBind.flatten(aa); + + bindCOne = Bindings.stringValueAt(c, 1); + bindCFour = Bindings.stringValueAt(c, 4); + } + + @Test + public void basicQuery() { + assertEquals(6, c.size()); + assertEquals("one", bindCOne.get()); + assertEquals("four", bindCFour.get()); + assertEquals("five", c.get(5)); + } + + @Test + public void removeValueSub() { + + ListChangeListener checkChange = c -> { + assertTrue(c.wasRemoved()); + assertEquals(1, c.getRemovedSize()); + + assertEquals("one", c.getRemoved().get(0)); + }; + + CountedChangeListener c_index1Removed = + new VerifyCountedChangeListener<>(checkChange, 1); + + CountedChangeListener d_index1Removed = + new VerifyCountedChangeListener<>(checkChange, 2); + + c.addListener(c_index1Removed); + d.addListener(d_index1Removed); + + a.remove(1); + assertEquals(5, c.size()); + assertEquals("three", c.get(2)); + assertEquals("two", bindCOne.get()); + assertEquals("five", bindCFour.get()); + assertEquals(1, c_index1Removed.getCallCount()); + + assertEquals(4, d.size()); + assertEquals("two", d.get(1)); + assertEquals("two", d.get(3)); + assertEquals(1, d_index1Removed.getCallCount()); + + } + + @Test + public void addElement() { + + CountedChangeListener c_index1Added = + new VerifyCountedChangeListener<>(c -> { + assertTrue(c.wasAdded()); + assertEquals(1, c.getAddedSize()); + + assertEquals("x", c.getAddedSubList().get(0)); + }, 1); + + c.addListener(c_index1Added); + d.addListener(failOnRunListener); + + b.add(1, "x"); // "three", "x", "four", "five" + + List expectedC = Arrays.asList("zero", "one", "two", "three", "x", "four", "five"); + assertEquals(expectedC, c); + assertEquals("x", c.get(a.size() + 1)); + assertEquals("x", bindCFour.get()); + assertEquals(1, c_index1Added.getCallCount()); + } + + @Test + public void setItem() { + ListChangeListener checkChange = c -> { + assertTrue(c.wasAdded()); + assertEquals(1, c.getAddedSize()); + + assertEquals("null", c.getAddedSubList().get(0)); + }; + + CountedChangeListener c_index0Update = + new VerifyCountedChangeListener<>(checkChange, 1); + + CountedChangeListener d_index0Update = + new VerifyCountedChangeListener<>(checkChange, 2); + + c.addListener(c_index0Update); + d.addListener(d_index0Update); + + // Trigger an "Added" event for the list.. because odd.. not Updated + a.set(0, "null"); + + List expectedC = Arrays.asList("null", "one", "two", "three", "four", "five"); + List expectedD = Arrays.asList("null", "one", "two", "null", "one", "two"); + + assertEquals(expectedC, c); + assertEquals(expectedD, d); + assertEquals(1, c_index0Update.getCallCount()); + assertEquals(1, d_index0Update.getCallCount()); + } + + @Test + public void removeList() { + ListChangeListener checkChange = c -> { + assertTrue(c.wasRemoved()); + + assertEquals(Arrays.asList("zero", "one", "two"), c.getRemoved()); + }; + + CountedChangeListener d_index0Update = + new VerifyCountedChangeListener<>(checkChange, 1); + + c.addListener(failOnRunListener); + d.addListener(d_index0Update); + + // Trigger an "Added" event for the list.. because odd.. not Updated + + aa.remove(a); + + List expectedC = Arrays.asList("zero", "one", "two", "three", "four", "five"); + List expectedD = Arrays.asList("zero", "one", "two"); + + assertEquals(expectedC, c); + assertEquals(expectedD, d); + assertEquals(1, d_index0Update.getCallCount()); + } + + private class VerifyCountedChangeListener extends CountedChangeListener { + private final int iterationCount; + private final ListChangeListener checkChange; + + VerifyCountedChangeListener(ListChangeListener checkChange, int iterationCount) { + this.checkChange = checkChange; + this.iterationCount = iterationCount; + } + + @Override + public void actualChange(Change c) { + int iterationCount = 0; + while (c.next()) { + checkChange.onChanged(c); + iterationCount++; + } + + assertEquals(this.iterationCount, iterationCount); + } + } + + private final CountedChangeListener failOnRunListener = new CountedChangeListener() { + @Override + public void actualChange(Change c) { + fail("Should not be called, d is not changed."); + } + }; +}