diff --git a/reactfx/build.gradle b/reactfx/build.gradle index cc8e5e1..04e4803 100644 --- a/reactfx/build.gradle +++ b/reactfx/build.gradle @@ -4,6 +4,7 @@ apply plugin: 'signing' group = 'org.reactfx' dependencies { + compile group: 'com.google.guava', name: 'guava', version: '18.0' testCompile group: 'junit', name: 'junit', version: '4.12' testCompile group: 'org.hamcrest', name: 'hamcrest-library', version: '1.3' testCompile group: 'org.junit.contrib', name: 'junit-theories', version: '4.12' diff --git a/reactfx/src/main/java/org/reactfx/collection/AccessorListMethods.java b/reactfx/src/main/java/org/reactfx/collection/AccessorListMethods.java index fb7dd44..78124b1 100644 --- a/reactfx/src/main/java/org/reactfx/collection/AccessorListMethods.java +++ b/reactfx/src/main/java/org/reactfx/collection/AccessorListMethods.java @@ -138,17 +138,17 @@ public int previousIndex() { @Override public void remove() { - throw new UnsupportedOperationException(); + list.remove(position--); } @Override public void set(E e) { - throw new UnsupportedOperationException(); + list.set(position - 1, e); } @Override public void add(E e) { - throw new UnsupportedOperationException(); + list.add(position++, e); } } diff --git a/reactfx/src/main/java/org/reactfx/collection/LiveArrayList.java b/reactfx/src/main/java/org/reactfx/collection/LiveArrayList.java index 043f447..fafa2f5 100644 --- a/reactfx/src/main/java/org/reactfx/collection/LiveArrayList.java +++ b/reactfx/src/main/java/org/reactfx/collection/LiveArrayList.java @@ -5,10 +5,11 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.RandomAccess; import org.reactfx.Subscription; -public final class LiveArrayList extends LiveListBase { +public final class LiveArrayList extends LiveListBase implements RandomAccess { private List list; public LiveArrayList() { diff --git a/reactfx/src/main/java/org/reactfx/collection/ObservableSortedArraySet.java b/reactfx/src/main/java/org/reactfx/collection/ObservableSortedArraySet.java new file mode 100644 index 0000000..94632a8 --- /dev/null +++ b/reactfx/src/main/java/org/reactfx/collection/ObservableSortedArraySet.java @@ -0,0 +1,328 @@ +package org.reactfx.collection; + +import static java.util.Collections.binarySearch; +import static java.util.Collections.emptySortedSet; + +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; + +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.WeakInvalidationListener; +import javafx.collections.SetChangeListener; + +import com.google.common.collect.Sets; +import org.reactfx.Subscription; + +/** + * Implementation of {@link ObservableSortedSet} based on {@link ArrayList}. + */ +public final class ObservableSortedArraySet extends AbstractSet implements ObservableSortedSet { + private final LiveList backing = new LiveArrayList<>(); + private final Map observables = new IdentityHashMap<>(); + private final Collection> observers = new CopyOnWriteArrayList<>(); + private final Collection invalidationListeners = new CopyOnWriteArrayList<>(); + private final Comparator comparator; + private final Function> resortListenFunction; + private final InvalidationListener resortListener, resortListenerWeak; + private final LiveList listView = new ListView(); + + private final class ListView extends LiveListBase implements ReadOnlyLiveListImpl { + @Override + public int size() { + return backing.size(); + } + + @Override + public E get(int index) { + return backing.get(index); + } + + @Override + protected Subscription observeInputs() { + return LiveList.observeQuasiChanges(backing, this::notifyObservers); + } + } + + @Override + public LiveList listView() { + return listView; + } + + /** + * Constructs a new {@link ObservableSortedArraySet}. + * + *

The {@code resortListenFunction} parameter takes a function that, + * given a value stored in the set, yields any number of + * {@link Observable}s. Whenever any of them are + * {@linkplain Observable#addListener(InvalidationListener) invalidated}, + * this set is resorted. This way, the sort order of the items in the set + * (and its {@linkplain #listView list view}) are kept up to date.

+ * + * @param comparator how the items in the set will be compared + * @param resortListenFunction triggers for re-sorting, as above + */ + public ObservableSortedArraySet(Comparator comparator, Function> resortListenFunction) { + this.comparator = comparator; + this.resortListenFunction = resortListenFunction; + + resortListener = obs -> resort(); + resortListenerWeak = new WeakInvalidationListener(resortListener); + } + + private void resort() { + backing.sort(comparator); + } + + private void onAdded(E o) { + Observable[] os = observables.computeIfAbsent(o, oo -> { + Collection osc = resortListenFunction.apply(oo); + return osc.toArray(new Observable[osc.size()]); + }); + + for (Observable oo : os) { + oo.addListener(resortListenerWeak); + } + + fire(new SetChangeListener.Change(this) { + @Override + public boolean wasAdded() { + return true; + } + + @Override + public boolean wasRemoved() { + return false; + } + + @Override + public E getElementAdded() { + return o; + } + + @Override + public E getElementRemoved() { + return null; + } + }); + } + + private void onRemoved(E o) { + for (Observable oo : observables.remove(o)) { + oo.removeListener(resortListenerWeak); + } + + fire(new SetChangeListener.Change(this) { + @Override + public boolean wasAdded() { + return false; + } + + @Override + public boolean wasRemoved() { + return true; + } + + @Override + public E getElementAdded() { + return null; + } + + @Override + public E getElementRemoved() { + return o; + } + }); + } + + private void fire(SetChangeListener.Change evt) { + for (SetChangeListener oo : observers) { + oo.onChanged(evt); + } + + for (InvalidationListener oo : invalidationListeners) { + oo.invalidated(this); + } + } + + @Override + public Iterator iterator() { + return new Iterator() { + private final Iterator it = backing.iterator(); + private E previous; + + @Override + public void forEachRemaining(Consumer action) { + previous = null; + it.forEachRemaining(action); + } + + @Override + public boolean hasNext() { + return it.hasNext(); + } + + @Override + public E next() { + previous = it.next(); + return previous; + } + + @Override + public void remove() { + it.remove(); + onRemoved(previous); + } + }; + } + + @Override + public void forEach(Consumer action) { + backing.forEach(action); + } + + @Override + public boolean isEmpty() { + return backing.isEmpty(); + } + + @Override + public Stream parallelStream() { + return backing.parallelStream(); + } + + @Override + public Stream stream() { + return backing.stream(); + } + + @Override + public void addListener(InvalidationListener listener) { + invalidationListeners.add(listener); + } + + @Override + public void removeListener(InvalidationListener listener) { + invalidationListeners.remove(listener); + } + + @Override + public Object[] toArray() { + return backing.toArray(); + } + + @Override + public T[] toArray(T[] a) { + return backing.toArray(a); + } + + @Override + public Spliterator spliterator() { + return backing.spliterator(); + } + + @Override + public int size() { + return backing.size(); + } + + @SuppressWarnings("unchecked") + @Override + public void clear() { + Object[] os = backing.toArray(); + backing.clear(); + + for (Object o : os) { + onRemoved((E) o); + } + } + + @Override + public void addListener(SetChangeListener listener) { + observers.add(listener); + } + + @Override + public void removeListener(SetChangeListener listener) { + observers.remove(listener); + } + + @Override + public Comparator comparator() { + return comparator; + } + + @Override + public SortedSet subSet(E fromElement, E toElement) { + if (Objects.equals(fromElement, toElement)) { + return emptySortedSet(); + } else { + return Sets.filter(this, e -> (comparator.compare(e, fromElement) >= 0) && (comparator.compare(e, toElement) < 0)); + } + } + + @Override + public SortedSet headSet(E toElement) { + return Sets.filter(this, e -> comparator.compare(e, toElement) < 0); + } + + @Override + public SortedSet tailSet(E fromElement) { + return Sets.filter(this, e -> comparator.compare(e, fromElement) >= 0); + } + + @Override + public E first() { + if (backing.isEmpty()) { + throw new NoSuchElementException(); + } else { + return backing.get(0); + } + } + + @Override + public E last() { + if (backing.isEmpty()) { + throw new NoSuchElementException(); + } else { + return backing.get(backing.size() - 1); + } + } + + @Override + public boolean add(E e) { + int pos = binarySearch(backing, e, comparator); + + if (pos >= 0) { + return false; + } else { + backing.add(-pos - 1, e); + onAdded(e); + return true; + } + } + + @SuppressWarnings("unchecked") + @Override + public boolean contains(Object o) { + int pos = binarySearch(backing, (E) o, comparator); + return (pos >= 0) && backing.get(pos).equals(o); + } + + @SuppressWarnings("unchecked") + @Override + public boolean remove(Object o) { + int pos = binarySearch(backing, (E) o, comparator); + + if ((pos >= 0) && backing.get(pos).equals(o)) { + backing.remove(pos); + onRemoved((E) o); + return true; + } else { + return false; + } + } +} \ No newline at end of file diff --git a/reactfx/src/main/java/org/reactfx/collection/ObservableSortedSet.java b/reactfx/src/main/java/org/reactfx/collection/ObservableSortedSet.java new file mode 100644 index 0000000..ba7052a --- /dev/null +++ b/reactfx/src/main/java/org/reactfx/collection/ObservableSortedSet.java @@ -0,0 +1,22 @@ +package org.reactfx.collection; + +import java.util.SortedSet; + +import javafx.collections.ObservableSet; + +/** + * A {@link SortedSet} that is also {@linkplain ObservableSet observable}. + * Implementations of this interface provide a read-only {@link #listView} of + * their contents, which is also sorted. + * + * @see ObservableSortedArraySet + */ +public interface ObservableSortedSet extends ObservableSet, SortedSet { + /** + * A read-only {@link LiveList} view of this + * {@link ObservableSortedSet}'s contents. It will issue events whenever + * items are added to or removed from this {@code ObservableSortedSet}, + * and when their sort order changes. + */ + LiveList listView(); +} \ No newline at end of file diff --git a/reactfx/src/test/java/org/reactfx/collection/ObservableSortedArraySetTest.java b/reactfx/src/test/java/org/reactfx/collection/ObservableSortedArraySetTest.java new file mode 100644 index 0000000..69f98ee --- /dev/null +++ b/reactfx/src/test/java/org/reactfx/collection/ObservableSortedArraySetTest.java @@ -0,0 +1,205 @@ +package org.reactfx.collection; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.util.Collections; + +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.collections.ListChangeListener; +import javafx.collections.SetChangeListener; + +import org.junit.Before; +import org.junit.Test; + +public final class ObservableSortedArraySetTest { + private ObservableSortedArraySet set; + private IntegerProperty prop1, prop2, prop3; + + @Before + public void init() { + set = new ObservableSortedArraySet<>( + (o1, o2) -> Integer.compare(o1.get(), o2.get()), + Collections::singleton + ); + + prop1 = new SimpleIntegerProperty(this, "prop1", 10); + prop2 = new SimpleIntegerProperty(this, "prop2", 20); + prop3 = new SimpleIntegerProperty(this, "prop3", 30); + } + + @Test + public void run() { + SetChangeListener.Change[] lastChange = new SetChangeListener.Change[1]; + + set.addListener((SetChangeListener.Change change) -> { + assert lastChange[0] == null + : "Spurious event: " + change; + + lastChange[0] = change; + }); + + assertThat(set, empty()); + assertThat(set.listView(), empty()); + assert !set.contains(prop1); + assert !set.remove(prop1); + + assert set.add(prop2); + assert !set.add(prop2); + assertThat(set, contains(prop2)); + assertThat(set.listView(), contains(prop2)); + assert set.contains(prop2); + assert !set.contains(prop1); + assert lastChange[0].wasAdded(); + assert !lastChange[0].wasRemoved(); + assertThat(lastChange[0].getElementAdded(), is(prop2)); + lastChange[0] = null; + + assert set.add(prop1); + assert !set.add(prop1); + assertThat(set, contains(prop1, prop2)); + assertThat(set.listView(), contains(prop1, prop2)); + assertThat(set.listView().get(1), is(prop2)); + assertThat(set.last(), is(prop2)); + assert set.contains(prop2); + assert set.contains(prop1); + assert lastChange[0].wasAdded(); + assert !lastChange[0].wasRemoved(); + assertThat(lastChange[0].getElementAdded(), is(prop1)); + lastChange[0] = null; + + prop2.set(5); + assertThat(set, contains(prop2, prop1)); + assertThat(set.listView(), contains(prop2, prop1)); + assertThat(set.listView().get(1), is(prop1)); + assertThat(set.last(), is(prop1)); + + assert set.add(prop3); + assert !set.add(prop3); + assertThat(set, contains(prop2, prop1, prop3)); + assertThat(set.listView(), contains(prop2, prop1, prop3)); + assertThat(set.listView().get(2), is(prop3)); + assertThat(set.listView().size(), is(3)); + assertThat(set.last(), is(prop3)); + assert lastChange[0].wasAdded(); + assert !lastChange[0].wasRemoved(); + assertThat(lastChange[0].getElementAdded(), is(prop3)); + lastChange[0] = null; + + assert set.remove(prop2); + assertThat(set, contains(prop1, prop3)); + assertThat(set.listView(), contains(prop1, prop3)); + assertThat(set.listView().get(0), is(prop1)); + assertThat(set.last(), is(prop3)); + assertThat(set.first(), is(prop1)); + assert !set.remove(prop2); + assert !lastChange[0].wasAdded(); + assert lastChange[0].wasRemoved(); + assertThat(lastChange[0].getElementRemoved(), is(prop2)); + } + + @Test + public void testRecursiveSetChanges() { + int[] gotEvents = { 0 }; + + set.addListener((SetChangeListener.Change change) -> { + switch (gotEvents[0]++) { + case 0: + assert change.wasAdded(); + assertThat(change.getElementAdded(), is(prop1)); + assertThat(set, contains(prop1)); + assertThat(set.listView(), contains(prop1)); + + set.add(prop2); + set.add(prop3); + break; + + case 1: + assert change.wasAdded(); + assertThat(change.getElementAdded(), is(prop2)); + assertThat(set, contains(prop1, prop2)); + assertThat(set.listView(), contains(prop1, prop2)); + break; + + case 2: + assert change.wasAdded(); + assertThat(change.getElementAdded(), is(prop3)); + assertThat(set, contains(prop1, prop2, prop3)); + assertThat(set.listView(), contains(prop1, prop2, prop3)); + + // This won't fire another set change event, since nothing is added or removed. It will, however, still have the side effect of reordering the items. + prop3.set(5); + assertThat(set, contains(prop3, prop1, prop2)); + assertThat(set.listView(), contains(prop3, prop1, prop2)); + break; + + default: + throw new AssertionError("Spurious event: " + change); + } + }); + + set.add(prop1); + assertThat("wrong number of set change events", gotEvents[0], is(3)); + } + + @Test + public void testRecursiveListViewChanges() { + int[] gotEvents = { 0 }; + + set.listView().addListener((ListChangeListener) change -> { + while (change.next()) { + switch (gotEvents[0]++) { + case 0: + assert change.wasAdded(); + assertThat(change.getFrom(), is(0)); + assertThat(change.getTo(), is(1)); + assertThat(change.getAddedSubList(), contains(prop1)); + assertThat(set, contains(prop1)); + assertThat(set.listView(), contains(prop1)); + + set.add(prop2); + set.add(prop3); + break; + + case 1: + assert change.wasAdded(); + assertThat(change.getFrom(), is(1)); + assertThat(change.getTo(), is(2)); + assertThat(change.getAddedSubList(), contains(prop2)); + assertThat(set, contains(prop1, prop2)); + assertThat(set.listView(), contains(prop1, prop2)); + break; + + case 2: + assert change.wasAdded(); + assertThat(change.getFrom(), is(2)); + assertThat(change.getTo(), is(3)); + assertThat(change.getAddedSubList(), contains(prop3)); + assertThat(set, contains(prop1, prop2, prop3)); + assertThat(set.listView(), contains(prop1, prop2, prop3)); + + prop3.set(5); + break; + + case 3: + assert change.wasPermutated(); + assertThat(change.getFrom(), is(0)); + assertThat(change.getTo(), is(3)); + assertThat(change.getPermutation(0), is(1)); + assertThat(change.getPermutation(1), is(2)); + assertThat(change.getPermutation(2), is(0)); + assertThat(set, contains(prop3, prop1, prop2)); + assertThat(set.listView(), contains(prop3, prop1, prop2)); + break; + + default: + throw new AssertionError("Spurious event: " + change); + } + } + }); + + set.add(prop1); + assertThat("wrong number of list change events", gotEvents[0], is(3)); + } +} \ No newline at end of file