on(properties)
+ .forKey("Key")
+ .forValueType(String.class)
+ .processValue(unreached)
+ .buildAttached();
+
+ // add a couple of values of the wrong type to average the time that takes
+ Integer valueOfWrongType = 5;
+ int runs = (int) 1e5;
+ long startTimeInNS = System.nanoTime();
+
+ for (int i = 0; i < runs; i++)
+ properties.put("Key", valueOfWrongType);
+
+ long endTimeInNS = System.nanoTime();
+ long timePerRunInNS = (endTimeInNS - startTimeInNS) / runs;
+ System.out.println("For checked casts, adding a value of the wrong type takes ~" + timePerRunInNS + " ns.");
+
+ System.out.println();
+ }
+
+ // #end DEMOS
+
+ /**
+ * TODO (nipa): I don't get it. The simple test below clearly shows that raising an exception takes about 6.000 ns.
+ * So why the hell does {@link #timeNoTypeCheck()} run way faster than that?!
+ *
+ * Some days later: I ran this again and discovered that the time difference is now very measurable and looks
+ * correct. Perhaps some JVM optimization because I ran it so often?
+ */
+ private void castVsTypeChecking() {
+ int runs = (int) 1e5;
+ Object integer = 3;
+
+ // CAST
+ long start = System.nanoTime();
+ for (int i = 0; i < runs; i++)
+ try {
+ String string = (String) integer;
+ System.out.println(string);
+ } catch (ClassCastException e) {
+ // do nothing
+ }
+ long end = System.nanoTime();
+ System.out.println("Each unchecked cast took ~" + (end - start) / runs + " ns.");
+
+ // TYPE CHECK
+ start = System.nanoTime();
+ for (int i = 0; i < runs; i++)
+ if (String.class.isInstance(integer)) {
+ String bar = (String) integer;
+ System.out.println(bar);
+ }
+ end = System.nanoTime();
+ System.out.println("Each type check took ~" + (end - start) / runs + " ns.");
+ }
+}
diff --git a/src/demo/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerDemo.java b/src/demo/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerDemo.java
new file mode 100644
index 0000000..35f6a4c
--- /dev/null
+++ b/src/demo/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerDemo.java
@@ -0,0 +1,154 @@
+package org.codefx.libfx.control.webview;
+
+import javafx.application.Application;
+import javafx.beans.property.BooleanProperty;
+import javafx.scene.Scene;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.Label;
+import javafx.scene.layout.VBox;
+import javafx.scene.paint.Color;
+import javafx.scene.web.WebView;
+import javafx.stage.Stage;
+
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkEvent.EventType;
+
+import org.codefx.libfx.listener.handle.ListenerHandle;
+
+/**
+ * Demonstrates how to use the {@link WebViewHyperlinkListener}.
+ */
+public class WebViewHyperlinkListenerDemo extends Application {
+
+ // #region INITIALIZATION
+
+ /**
+ * Runs this demo.
+ *
+ * @param args
+ * command line arguments (will not be used)
+ */
+ public static void main(String[] args) {
+ Application.launch(args);
+ }
+
+ @Override
+ public void start(Stage primaryStage) throws Exception {
+ // controls
+ WebView webView = createWebView();
+ Label urlLabel = createUrlLabel();
+ CheckBox listenerAttachedBox = createListenerAttachedBox();
+ CheckBox cancelEventBox = createCancelEventBox();
+
+ // listener
+ WebViewHyperlinkListener listener = event -> {
+ showEventOnLabel(event, urlLabel);
+ return cancelEventBox.isSelected();
+ };
+ manageListener(webView, listener, listenerAttachedBox.selectedProperty());
+
+ // put together
+ VBox box = new VBox(webView, listenerAttachedBox, cancelEventBox, urlLabel);
+ Scene scene = new Scene(box);
+ primaryStage.setScene(scene);
+ primaryStage.show();
+ }
+
+ /**
+ * Creates the web view to which the listener will be attached.
+ *
+ * @return a {@link WebView}
+ */
+ private static WebView createWebView() {
+ WebView webView = new WebView();
+ webView.getEngine().getLoadWorker().stateProperty().addListener(
+ (obs, o, n) -> System.out.println("WEB VIEW WORKER STATUS: " + n));
+ webView.getEngine().load("https://en.wikipedia.org/wiki/Main_Page");
+ return webView;
+ }
+
+ /**
+ * Creates the Label which will display the URL.
+ *
+ * @return a {@link Label}
+ */
+ private static Label createUrlLabel() {
+ return new Label();
+ }
+
+ /**
+ * Creates the check box with which the listener can be attached and detached.
+ *
+ * @return a {@link CheckBox}
+ */
+ private static CheckBox createListenerAttachedBox() {
+ return new CheckBox("hyperlink listener attached");
+ }
+
+ /**
+ * Creates the check box with which the further processing of events can be cancelled.
+ *
+ * @return a {@link CheckBox}
+ */
+ private static CheckBox createCancelEventBox() {
+ return new CheckBox("cancel event processing");
+ }
+
+ // #end INITIALIZATION
+
+ // #region LISTENER
+
+ /**
+ * Attaches/detaches the specified listener to/from the specified web view according to the specified property's
+ * value.
+ *
+ * @param webView
+ * the {@link WebView} to which the listener will be added
+ * @param listener
+ * the added listener
+ * @param attachedProperty
+ * defines whether the listener is attached or not
+ */
+ private static void manageListener(WebView webView, WebViewHyperlinkListener listener,
+ BooleanProperty attachedProperty) {
+ attachedProperty.set(true);
+ ListenerHandle listenerHandle = WebViews.addHyperlinkListener(webView, listener);
+
+ attachedProperty.addListener((obs, wasAttached, isAttached) -> {
+ if (isAttached) {
+ listenerHandle.attach();
+ System.out.println("LISTENER: attached.");
+ } else {
+ listenerHandle.detach();
+ System.out.println("LISTENER: detached.");
+ }
+ });
+ }
+
+ /**
+ * Visualizes the specified event's type and URL on the specified label.
+ *
+ * @param event
+ * the {@link HyperlinkEvent} to visualize
+ * @param urlLabel
+ * the {@link Label} which will visualize the event
+ */
+ private static void showEventOnLabel(HyperlinkEvent event, Label urlLabel) {
+ if (event.getEventType() == EventType.ENTERED) {
+ urlLabel.setTextFill(Color.BLACK);
+ urlLabel.setText("ENTERED: " + event.getURL().toExternalForm());
+ System.out.println("EVENT: " + WebViews.hyperlinkEventToString(event));
+ } else if (event.getEventType() == EventType.EXITED) {
+ urlLabel.setTextFill(Color.BLACK);
+ urlLabel.setText("EXITED: " + event.getURL().toExternalForm());
+ System.out.println("EVENT: " + WebViews.hyperlinkEventToString(event));
+ } else if (event.getEventType() == EventType.ACTIVATED) {
+ urlLabel.setText("ACTIVATED: " + event.getURL().toExternalForm());
+ urlLabel.setTextFill(Color.RED);
+ System.out.println("EVENT: " + WebViews.hyperlinkEventToString(event));
+ }
+ }
+
+ // #end LISTENER
+
+}
diff --git a/src/demo/java/org/codefx/libfx/listener/handle/ListenerHandleDemo.java b/src/demo/java/org/codefx/libfx/listener/handle/ListenerHandleDemo.java
new file mode 100644
index 0000000..cd3ac64
--- /dev/null
+++ b/src/demo/java/org/codefx/libfx/listener/handle/ListenerHandleDemo.java
@@ -0,0 +1,155 @@
+package org.codefx.libfx.listener.handle;
+
+import javafx.beans.Observable;
+import javafx.beans.property.Property;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.value.ChangeListener;
+
+/**
+ * Demonstrates how to create and use {@link ListenerHandle}s.
+ */
+@SuppressWarnings("static-method")
+public class ListenerHandleDemo {
+
+ // #region CONSTRUCTION & MAIN
+
+ /**
+ * Creates a new demo.
+ */
+ private ListenerHandleDemo() {
+ // nothing to do
+ }
+
+ /**
+ * Runs this demo.
+ *
+ * @param args
+ * command line arguments (will not be used)
+ */
+ public static void main(String[] args) {
+ ListenerHandleDemo demo = new ListenerHandleDemo();
+
+ demo.createCommonListenerHandle();
+ demo.createCustomListenerHandle();
+ demo.attachAndDetach();
+ }
+
+ // #end CONSTRUCTION & MAIN
+
+ // #region DEMOS
+
+ // construction
+
+ /**
+ * Demonstrates how to simply create a handle for a given observable and listener.
+ */
+ private void createCommonListenerHandle() {
+ Property property = new SimpleStringProperty();
+ ChangeListener listener = (obs, oldValue, newValue) -> { /* do nothing for this demo */};
+
+ // create the handle; this one is initially attached, i.e. the listener is added to the property
+ ListenerHandle handle = ListenerHandles.createAttached(property, listener);
+ // the handle can be used to easily detach and reattach the listener
+ handle.detach();
+ handle.attach();
+
+ // create a detached handle where the listener was not yet added to the property
+ handle = ListenerHandles.createDetached(property, listener);
+ // this one needs to be attached before the listener is executed on changes
+ handle.attach();
+ }
+
+ /**
+ * Demonstrates how a listener handle can be created for custom observable implementations with
+ * {@link ListenerHandleBuilder}.
+ */
+ private void createCustomListenerHandle() {
+ MyCustomObservable customObservable = new MyCustomObservable();
+ MyCustomListener customListener = new MyCustomListener();
+
+ // use 'ListenerHandles' to get a 'ListenerHandleBuilder' which can be used to create a handle for this
+ // observable and listener
+ ListenerHandles
+ .createFor(customObservable, customListener)
+ .onAttach((observable, listener) -> observable.addListener(listener))
+ .onDetach((observable, listener) -> observable.removeListener(listener))
+ .buildAttached();
+ }
+
+ // attach & detach
+
+ /**
+ * Demonstrates how to add and remove a listener with a {@link ListenerHandle} and compares this to the normal
+ * approach.
+ */
+ private void attachAndDetach() {
+ Property observedProperty = new SimpleStringProperty("initial value");
+
+ // usually a listener is directly added to the property;
+ // but if the listener has to be removed later, the reference needs to be stored explicitly
+ ChangeListener changePrintingListener = (obs, oldValue, newValue) ->
+ System.out.println("[LISTENER] Value changed from \"" + oldValue + "\" to \"" + newValue + "\".");
+ observedProperty.addListener(changePrintingListener);
+
+ // this is the alternative with a 'ListenerHandle'
+ ListenerHandle newValuePrinter = ListenerHandles.createAttached(observedProperty,
+ (obs, oldValue, newValue) -> System.out.println("[HANDLE] New value: \"" + newValue + "\""));
+
+ // now lets change the value to see how it works
+ observedProperty.setValue("new value");
+ observedProperty.setValue("even newer value");
+
+ // removing a listener needs references to both the observable and the listener;
+ // depending on the situation this might not be feasible
+ observedProperty.removeListener(changePrintingListener);
+ // with a handle, the listener can be removed without giving the caller the possibility tp interact with
+ // the observable or the listener; it is also a little more readable
+ newValuePrinter.detach();
+
+ // some unobserved changes...
+ observedProperty.setValue("you won't see this on the console");
+ observedProperty.setValue("nor this");
+
+ // the same as above goes for adding the listener
+ observedProperty.addListener(changePrintingListener);
+ newValuePrinter.attach();
+
+ // now some more changes
+ observedProperty.setValue("but you will see this");
+ observedProperty.setValue("and this");
+ }
+
+ // #end DEMOS
+
+ // #region NESTED CLASSES
+
+ /**
+ * Represents a custom observable instance. Note that it is not necessary to implement {@link Observable} (or any
+ * other interface) in order to use this class with the {@link ListenerHandleBuilder}.
+ */
+ private static class MyCustomObservable {
+
+ @SuppressWarnings({ "javadoc", "unused" })
+ public void addListener(MyCustomListener listener) {
+ // do nothing - just for demo
+ }
+
+ @SuppressWarnings({ "javadoc", "unused" })
+ public void removeListener(MyCustomListener listener) {
+ // do nothing - just for demo
+ }
+
+ }
+
+ /**
+ * Represents a listener for a custom observable instance. Note that it is not necessary to implement
+ * {@link ChangeListener} (or any other interface) in order to use this class with the {@link ListenerHandleBuilder}
+ * .
+ */
+ private static class MyCustomListener {
+ // has no members - just for demo
+ }
+
+ // #end NESTED CLASSES
+
+}
diff --git a/src/demo/java/org/codefx/libfx/nesting/NestedDemo.java b/src/demo/java/org/codefx/libfx/nesting/NestedDemo.java
index e6f82e3..d98ca13 100644
--- a/src/demo/java/org/codefx/libfx/nesting/NestedDemo.java
+++ b/src/demo/java/org/codefx/libfx/nesting/NestedDemo.java
@@ -17,14 +17,14 @@
*/
public class NestedDemo {
- // #region ATTRIBUTES
+ // #region FIELDS
/**
* The currently selected employee.
*/
private final Property currentEmployee;
- //#end ATTRIBUTES
+ //#end FIELDS
// #region CONSTRUCTION & MAIN
diff --git a/src/demo/java/org/codefx/libfx/serialization/SerializableOptionalDemo.java b/src/demo/java/org/codefx/libfx/serialization/SerializableOptionalDemo.java
new file mode 100644
index 0000000..22d4198
--- /dev/null
+++ b/src/demo/java/org/codefx/libfx/serialization/SerializableOptionalDemo.java
@@ -0,0 +1,176 @@
+package org.codefx.libfx.serialization;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.NotSerializableException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Optional;
+import java.util.Random;
+
+/**
+ * Demonstrates how to use the {@link SerializableOptional}.
+ */
+@SuppressWarnings("static-method")
+public class SerializableOptionalDemo {
+
+ /**
+ * Runs this demo.
+ *
+ * @param args
+ * command line arguments (will not be used)
+ * @throws Exception
+ * if (de)serialization fails
+ */
+ public static void main(String[] args) throws Exception {
+ SerializableOptionalDemo demo = new SerializableOptionalDemo();
+
+ demo.serializeString();
+ demo.failSerializingOptional();
+ demo.serializeEmptySerializableOptional();
+ demo.serializeNonEmptySerializableOptional();
+
+ print("");
+
+ demo.callMethodsWithSerializableOptional();
+ }
+
+ // DEMO
+
+ // serialize "simple" objects, i.e. ones which contain no further instances, to demo serialization in general
+
+ /**
+ * To get started, serialize a string, deserialize it and print its value.
+ *
+ * @throws Exception
+ * if (de)serialization fails
+ */
+ private void serializeString() throws Exception {
+ String someString = "a string";
+ String deserializedString = serializeAndDeserialize(someString);
+ print("The deserialized 'String' is \"" + deserializedString + "\".");
+ }
+
+ /**
+ * Try the same with an {@code Optional}, which will fail as {@link Optional} is not {@link Serializable}.
+ *
+ * @throws Exception
+ * if (de)serialization fails
+ */
+ private void failSerializingOptional() throws Exception {
+ try {
+ Optional someOptional = Optional.of("another string");
+ Optional deserializedOptional = serializeAndDeserialize(someOptional);
+ print("The deserialized 'Optional' should have the value \"" + deserializedOptional.get() + "\".");
+ } catch (NotSerializableException e) {
+ print("Serialization of 'Optional' failed as expected.");
+ }
+ }
+
+ /**
+ * Create a {@link SerializableOptional} from an empty {@link Optional} and (de)serialize it successfully.
+ *
+ * @throws Exception
+ * if (de)serialization fails
+ */
+ private void serializeEmptySerializableOptional() throws Exception {
+ Optional someOptional = Optional.empty();
+ SerializableOptional serializableOptional = SerializableOptional.fromOptional(someOptional);
+ Optional deserializedOptional = serializeAndDeserialize(serializableOptional).asOptional();
+ print("The deserialized empty 'SerializableOptional' has no value: " + !deserializedOptional.isPresent() + ".");
+ }
+
+ /**
+ * Create a {@link SerializableOptional} from a nonempty {@link Optional} and (de)serialize it successfully.
+ *
+ * @throws Exception
+ * if (de)serialization fails
+ */
+ private void serializeNonEmptySerializableOptional() throws Exception {
+ Optional someOptional = Optional.of("another string");
+ SerializableOptional serializableOptional = SerializableOptional.fromOptional(someOptional);
+ Optional deserializedOptional = serializeAndDeserialize(serializableOptional).asOptional();
+ print("The deserialized non-empty 'SerializableOptional' has the value \"" + deserializedOptional.get() + "\".");
+ }
+
+ // use 'SerializableOptional' in method signatures
+
+ /**
+ * Shows how to quickly wrap and unwrap an {@link Optional} for RPC method calls which rely on serialization.
+ *
+ * Note that {@link SearchAndLog}'s methods have {@link SerializableOptional} as argument and return type.
+ */
+ private void callMethodsWithSerializableOptional() {
+ SearchAndLog searchAndLog = new SearchAndLog();
+ for (int id = 0; id < 7; id++) {
+ // unwrap the returned optional using 'asOptional'
+ Optional searchResult = searchAndLog.search(id).asOptional();
+ // wrap the optional using 'fromOptional'; if used often, this could be a static import
+ searchAndLog.log(id, SerializableOptional.fromOptional(searchResult));
+ }
+ }
+
+ // USABILITY
+
+ /**
+ * Serializes the specified instance to disk. Then deserializes the file and returns the deserialized value.
+ *
+ * @param
+ * the type of the serialized instance
+ * @param serialized
+ * the instance to be serialized
+ * @return the deserialized instance
+ * @throws Exception
+ * if (de)serialization fails
+ */
+ private static T serializeAndDeserialize(T serialized) throws Exception {
+ File serializeFile = new File("_serialized");
+ // serialize
+ try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(serializeFile))) {
+ out.writeObject(serialized);
+ }
+ // deserialize
+ try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(serializeFile))) {
+ @SuppressWarnings("unchecked")
+ T deserialized = (T) in.readObject();
+ return deserialized;
+ }
+ }
+
+ /**
+ * Prints the specified text to the console.
+ *
+ * @param text
+ * the text to print
+ */
+ private static void print(String text) {
+ System.out.println(text);
+ }
+
+ // INNER CLASS FOR METHOD CALLS
+
+ /**
+ * A class with methods which have an optional return value or argument.
+ */
+ @SuppressWarnings("javadoc")
+ private static class SearchAndLog {
+
+ Random random = new Random();
+
+ public SerializableOptional search(@SuppressWarnings("unused") int id) {
+ boolean searchSuccessfull = random.nextBoolean();
+ if (searchSuccessfull)
+ return SerializableOptional.of("found something!");
+ else
+ return SerializableOptional.empty();
+ }
+
+ public void log(int id, SerializableOptional item) {
+ print("Search result for id " + id + ": " + item.asOptional().orElse("empty search result"));
+ }
+
+ }
+
+}
diff --git a/src/main/java/org/codefx/libfx/concurrent/when/ExecuteAlwaysWhen.java b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteAlwaysWhen.java
new file mode 100644
index 0000000..f24d93d
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteAlwaysWhen.java
@@ -0,0 +1,197 @@
+package org.codefx.libfx.concurrent.when;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+
+/**
+ * Executes an action when an {@link ObservableValue}'s value fulfills a certain condition.
+ *
+ * The action will not be executed before {@link #executeWhen()} is called. The action is executed every time the value
+ * passes the condition. If this can happen in parallel in several threads, the action must be thread-safe as no further
+ * synchronization is provided by this class. Further execution can be prevented by calling {@link #cancel()}.
+ *
+ * This class guarantees that regardless of the way different threads interact with the {@code ObservableValue} the
+ * action will be executed...
+ *
+ * ... once during {@code executeWhen()} if either the observable's initial value or one it was changed to passes
+ * the condition
+ * ... every time a new value passes the condition after {@code executeWhen()} returns
+ *
+ * If the observable is manipulated by several threads during {@code executeWhen()}, this class does not guarantee that
+ * the first value to pass the condition is the one handed to the action. Depending on the interaction of those threads
+ * it might be the initial value or one of several which were set by those threads.
+ *
+ * Use {@link ExecuteWhen} to build an instance of this class.
+ *
+ * @param
+ * the type the observed {@link ObservableValue}'s wraps
+ */
+public class ExecuteAlwaysWhen {
+
+ /*
+ * If no other threads were involved the class would be simple. It would suffice to check the observable's current
+ * value. If it passes the condition, execute the action. Also, a listener which processes each new value in the
+ * same way has to be added.
+ */
+ /*
+ * But since other threads are allowed to interfere, this could fail. If a correct value is set between the check
+ * and attaching the listener, this value would not be processed and the action would not be executed. To prevent
+ * this the listener is added first and only then is the current value checked and the action possibly executed.
+ */
+ /*
+ * Now, if another thread sets the correct value after the listener was added but before the current value is
+ * processed, the action would be executed twice. Actually, if the value is switched fast enough by different
+ * threads, each could be in the middle of executing the listener when the check of the initial value finally
+ * proceeds. This would lead to multiple desired executions plus the undesired one of the initial check. (Note that
+ * this problem can only occur until the initial value is processed. After that only the listener can execute the
+ * action and there is only one so no funny stuff can happen.) That is the reason why the contract states that a
+ * correct value will only be processed once during executeWhen().
+ */
+ /*
+ * To achieve this, two atomic booleans are used. The first, 'executeAlways', is false after construction and will
+ * be set to true at the end of 'executeWhen'. When it is true, all values will be processed. Until then only one
+ * execution is allowed. This is monitored using 'alreadyExecuted', which is initially false and will be set to true
+ * on the first execution of the action.
+ */
+
+ // #region FIELDS
+
+ /**
+ * The {@link ObservableValue} upon whose value the action's execution depends.
+ */
+ private final ObservableValue observable;
+
+ /**
+ * The condition the {@link #observable}'s value must fulfill for {@link #action} to be executed.
+ */
+ private final Predicate super T> condition;
+
+ /**
+ * The action which will be executed.
+ */
+ private final Consumer super T> action;
+
+ /**
+ * The listener which executes {@link #action} and sets {@link #alreadyExecuted} accordingly.
+ */
+ private final ChangeListener super T> listenerWhichExecutesAction;
+
+ /**
+ * Indicates whether {@link #executeWhen()} was already called. If so, it can not be called again.
+ */
+ private final AtomicBoolean executeWhenWasAlreadyCalled;
+
+ /**
+ * Indicates whether {@link #action} is executed each time the value passes the {@link #condition}.
+ */
+ private final AtomicBoolean executeAlways;
+
+ /**
+ * Indicates whether {@link #action} was already executed once.
+ */
+ private final AtomicBoolean alreadyExecuted;
+
+ // #end FIELDS
+
+ /**
+ * Creates a new instance from the specified arguments. *
+ *
+ * Note that for the action to be executed, {@link #executeWhen()} needs to be called.
+ *
+ * @param observable
+ * the {@link ObservableValue} upon whose value the action's execution depends
+ * @param condition
+ * the condition the {@link #observable}'s value must fulfill for {@link #action} to be executed
+ * @param action
+ * the action which will be executed
+ */
+ ExecuteAlwaysWhen(ObservableValue observable, Predicate super T> condition, Consumer super T> action) {
+ this.observable = observable;
+ this.condition = condition;
+ this.action = action;
+
+ listenerWhichExecutesAction = (obs, oldValue, newValue) -> tryExecuteAction(newValue);
+ executeAlways = new AtomicBoolean(false);
+ executeWhenWasAlreadyCalled = new AtomicBoolean(false);
+ alreadyExecuted = new AtomicBoolean(false);
+ }
+
+ // #region METHODS
+
+ /**
+ * Executes the action (every time) when the observable's value passes the condition.
+ *
+ * This is a one way function that must only be called once. Calling it again throws an
+ * {@link IllegalStateException}.
+ *
+ * Call {@link #cancel()} to prevent further execution.
+ *
+ * @throws IllegalStateException
+ * if this method is called more than once
+ */
+ public void executeWhen() throws IllegalStateException {
+ boolean wasAlreadyCalled = executeWhenWasAlreadyCalled.getAndSet(true);
+ if (wasAlreadyCalled)
+ throw new IllegalStateException("The method 'executeWhen' must only be called once.");
+
+ observable.addListener(listenerWhichExecutesAction);
+ tryExecuteAction(observable.getValue());
+ executeAlways.set(true);
+ }
+
+ /**
+ * Executes {@link #action} if the specified value fulfills the {@link #condition}. Sets {@link #alreadyExecuted} to
+ * indicate that the action was executed at least once.
+ *
+ * Called by {@link #listenerWhichExecutesAction} every time {@link #observable} changes its value.
+ *
+ * @param currentValue
+ * the {@link #observable}'s current value
+ */
+ private void tryExecuteAction(T currentValue) {
+ boolean valueFailsGateway = !condition.test(currentValue);
+ if (valueFailsGateway)
+ return;
+
+ boolean canNotExecuteNow = !canExecuteNow();
+ if (canNotExecuteNow)
+ return;
+
+ action.accept(currentValue);
+ }
+
+ /**
+ * Indicates whether the {@link #action} can be executed now by checking {@link #executeAlways} and
+ * {@link #alreadyExecuted}.
+ *
+ * Potentially changes the state of {@code alreadyExecuted} so it must only be called if {@link #action} is really
+ * executed afterwards (i.e. the value should already have passed the {@link #condition}).
+ *
+ * @return true if the {@link #action} can be executed now
+ */
+ private boolean canExecuteNow() {
+ if (executeAlways.get()) {
+ alreadyExecuted.set(true);
+ return true;
+ }
+
+ // in this case the action must only be executed once, so make sure that didn't happen yet
+ boolean alreadyExecutedOnce = alreadyExecuted.getAndSet(true);
+ boolean canExecuteNow = !alreadyExecutedOnce;
+ return canExecuteNow;
+ }
+
+ /**
+ * Cancels the future execution of the action. If {@link #executeWhen()} was not yet called or the action was
+ * already executed, this is a no-op.
+ */
+ public void cancel() {
+ observable.removeListener(listenerWhichExecutesAction);
+ }
+
+ // #end METHODS
+}
diff --git a/src/main/java/org/codefx/libfx/concurrent/when/ExecuteOnceWhen.java b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteOnceWhen.java
new file mode 100644
index 0000000..9e9f7e4
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteOnceWhen.java
@@ -0,0 +1,161 @@
+package org.codefx.libfx.concurrent.when;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+
+/**
+ * Executes an action when an {@link ObservableValue}'s value fulfills a certain condition.
+ *
+ * The action will not be executed before {@link #executeWhen()} is called. The action is only executed once. If it was
+ * not yet executed, this can be prevented by calling {@link #cancel()}.
+ *
+ * This class guarantees that regardless of the way different threads interact with the {@code ObservableValue} the
+ * action will be executed...
+ *
+ * ... if the value held when {@code executeWhen()} returns passes the condition
+ * ... if a new value passes the condition (either during {@code executeWhen()} or after it returns)
+ * ... at most once
+ *
+ * If the observable is manipulated by several threads, this class does not guarantee that the first value to pass the
+ * condition is the one handed to the action. Depending on the interaction of those threads it might be the initial
+ * value (the one tested during {@code executeWhen()}) or one of several which were set by those threads.
+ *
+ * Use {@link ExecuteWhen} to build an instance of this class.
+ *
+ * @param
+ * the type the observed {@link ObservableValue}'s wraps
+ */
+public class ExecuteOnceWhen {
+
+ /*
+ * If no other threads were involved the class would be simple. It would suffice to check the observable's current
+ * value. If it passes the condition, execute the action; otherwise attach a listener which processes each new value
+ * in the same way.
+ */
+ /*
+ * But since other threads are allowed to interfere, this could fail. If a correct value is set between the check
+ * and attaching the listener, this value would not be processed and the action would not be executed. To prevent
+ * this the listener is added first and only then is the current value checked and the action possibly executed.
+ */
+ /*
+ * Now, if another thread sets the correct value after the listener was added but before the current value is
+ * processed, the action would be executed twice. To prevent this from happening an atomic boolean is used. It will
+ * contain true when the action can still be executed. When a value (either the initial or a new one) fulfills the
+ * condition, it is checked (and set to false). If it contained true, the action will be executed.
+ */
+
+ // #region FIELDS
+
+ /**
+ * The {@link ObservableValue} upon whose value the action's execution depends.
+ */
+ private final ObservableValue observable;
+
+ /**
+ * The condition the {@link #observable}'s value must fulfill for {@link #action} to be executed.
+ */
+ private final Predicate super T> condition;
+
+ /**
+ * The action which will be executed.
+ */
+ private final Consumer super T> action;
+
+ /**
+ * Indicates whether {@link #action} might still be executed at some point in the future. Is used to prevent the
+ * listener and the initial check (see {@link #executeWhen()}) to both execute the action.
+ */
+ private final AtomicBoolean willExecute;
+
+ /**
+ * The listener which executes {@link #action} and sets {@link #willExecute} accordingly.
+ */
+ private final ChangeListener listenerWhichExecutesAction;
+
+ /**
+ * Indicates whether {@link #executeWhen()} was already called. If so, it can not be called again.
+ */
+ private final AtomicBoolean executeWhenWasAlreadyCalled;
+
+ // #end FIELDS
+
+ /**
+ * Creates a new instance from the specified arguments.
+ *
+ * Note that for the action to be executed, {@link #executeWhen()} needs to be called.
+ *
+ * @param observable
+ * the {@link ObservableValue} upon whose value the action's execution depends
+ * @param condition
+ * the condition the {@link #observable}'s value must fulfill for {@link #action} to be executed
+ * @param action
+ * the action which will be executed
+ */
+ ExecuteOnceWhen(ObservableValue observable, Predicate super T> condition, Consumer super T> action) {
+ this.observable = observable;
+ this.condition = condition;
+ this.action = action;
+
+ listenerWhichExecutesAction = (obs, oldValue, newValue) -> tryExecuteAction(newValue);
+ executeWhenWasAlreadyCalled = new AtomicBoolean(false);
+ willExecute = new AtomicBoolean(true);
+ }
+
+ // #region METHODS
+
+ /**
+ * Executes the action (once) when the observable's value passes the condition.
+ *
+ * This is a one way function that must only be called once. Calling it again throws an
+ * {@link IllegalStateException}.
+ *
+ * Call {@link #cancel()} to prevent future execution.
+ *
+ * @throws IllegalStateException
+ * if this method is called more than once
+ */
+ public void executeWhen() throws IllegalStateException {
+ boolean wasAlreadyCalled = executeWhenWasAlreadyCalled.getAndSet(true);
+ if (wasAlreadyCalled)
+ throw new IllegalStateException("The method 'executeWhen' can only be called once.");
+
+ observable.addListener(listenerWhichExecutesAction);
+ tryExecuteAction(observable.getValue());
+ }
+
+ /**
+ * Executes {@link #action} if the specified value fulfills the {@link #condition} and the action was not yet
+ * executed. The latter is indicated by the {@link #willExecute}, which will also be updated.
+ *
+ * @param currentValue
+ * the {@link #observable}'s current value
+ */
+ private void tryExecuteAction(T currentValue) {
+ boolean valueFailsGateway = !condition.test(currentValue);
+ if (valueFailsGateway)
+ return;
+
+ boolean actionCanBeExecuted = willExecute.getAndSet(false);
+ if (actionCanBeExecuted) {
+ action.accept(currentValue);
+ // the action was just executed and will not be executed again so the listener is not needed anymore
+ observable.removeListener(listenerWhichExecutesAction);
+ }
+ }
+
+ /**
+ * Cancels the future execution of the action. If {@link #executeWhen()} was not yet called or the action was
+ * already executed, this is a no-op.
+ */
+ public void cancel() {
+ willExecute.set(false);
+ observable.removeListener(listenerWhichExecutesAction);
+ }
+
+ // #end METHODS
+
+}
diff --git a/src/main/java/org/codefx/libfx/concurrent/when/ExecuteWhen.java b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteWhen.java
new file mode 100644
index 0000000..cd80fe6
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/concurrent/when/ExecuteWhen.java
@@ -0,0 +1,147 @@
+package org.codefx.libfx.concurrent.when;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import javafx.beans.value.ObservableValue;
+
+/**
+ *
+ * Builder for {@link ExecuteAlwaysWhen} and {@link ExecuteOnceWhen}.
+ *
+ * Example A typical use would look like this:
+ *
+ *
+ * ObservableValue<State> workerState;
+ * ExecuteWhen.on(workerState)
+ * .when(state -> state == State.SUCCEEDED)
+ * .thenOnce(state -> logSuccess())
+ * .executeWhen();
+ *
+ *
+ * @param
+ * the type the {@link ObservableValue} which will be observed by the constructed instance wraps
+ */
+public class ExecuteWhen {
+
+ // #region FIELDS
+
+ /**
+ * The {@link ObservableValue} upon whose value the action's execution depends.
+ */
+ private final ObservableValue observable;
+
+ /**
+ * The condition the {@link #observable}'s value must fulfill for the action to be executed.
+ */
+ private Optional> condition;
+
+ // #end FIELDS
+
+ // #region CONSTRUCTION
+
+ /**
+ * Creates a new instance for the specified observable
+ *
+ * @param observable
+ * the {@link ObservableValue} which will be observed by the created {@code Execute...When} instances
+ */
+ private ExecuteWhen(ObservableValue observable) {
+ this.observable = observable;
+ condition = Optional.empty();
+ }
+
+ /**
+ * Creates a new builder. The built instance of {@code Execute...When} will observe the specified observable.
+ *
+ * @param
+ * the type the {@link ObservableValue} which will be observed by the constructed instance wraps
+ * @param observable
+ * the {@link ObservableValue} which will be observed by the created {@code Execute...When} instances
+ * @return a new builder instance
+ */
+ public static ExecuteWhen on(ObservableValue observable) {
+ return new ExecuteWhen<>(observable);
+ }
+
+ // #end CONSTRUCTION
+
+ // #region SETTING VALUES
+
+ /**
+ * Specifies the condition the observable's value must fulfill in order for the action to be executed.
+ *
+ * @param condition
+ * the condition as a {@link Predicate}
+ * @return this builder
+ */
+ public ExecuteWhen when(Predicate super T> condition) {
+ Objects.requireNonNull(condition, "The argument 'condition' must not be null.");
+ this.condition = Optional.of(condition);
+ return this;
+ }
+
+ // #end SETTING VALUES
+
+ // #region BUILD
+
+ /**
+ * Creates an instance which:
+ *
+ * observes the {@link ObservableValue} (specified for this builder's construction) for new values
+ * checks each new value against the condition set with {@link #when(Predicate)} (calling which is required)
+ * executes the specified {@code action} once if a value fulfills the condition
+ *
+ * Note that the observation does not start until {@link ExecuteOnceWhen#executeWhen()} is called. See
+ * {@link ExecuteOnceWhen} for details.
+ *
+ * @param action
+ * the {@link Consumer} of the value which passed the condition
+ * @return an instance of {@link ExecuteOnceWhen}
+ * @throws IllegalStateException
+ * if {@link #when(Predicate)} was not called
+ */
+ public ExecuteOnceWhen thenOnce(Consumer super T> action) throws IllegalStateException {
+ ensureConditionWasSet();
+ return new ExecuteOnceWhen(observable, condition.get(), action);
+ }
+
+ /**
+ * Creates an instance which:
+ *
+ * observes the {@link ObservableValue} (specified for this builder's construction) for new values
+ * checks each new value against the condition set with {@link #when(Predicate)} (calling which is required)
+ * executes the specified {@code action} every time a value fulfills the condition
+ *
+ * Note that the observation does not start until {@link ExecuteAlwaysWhen#executeWhen()} is called. See
+ * {@link ExecuteAlwaysWhen} for details.
+ *
+ * @param action
+ * the {@link Consumer} of the value which passed the condition
+ * @return an instance of {@link ExecuteOnceWhen}
+ * @throws IllegalStateException
+ * if {@link #when(Predicate)} was not called
+ */
+ public ExecuteAlwaysWhen thenAlways(Consumer super T> action) throws IllegalStateException {
+ ensureConditionWasSet();
+ return new ExecuteAlwaysWhen(observable, condition.get(), action);
+ }
+
+ /**
+ * Makes sure that {@link #condition} was set, i.e. the {@link Optional} is not empty.
+ *
+ * @throws IllegalStateException
+ * if {@link #condition} was not set
+ */
+ private void ensureConditionWasSet() throws IllegalStateException {
+ boolean noCondition = !condition.isPresent();
+ if (noCondition)
+ throw new IllegalStateException(
+ "Set a condition with 'when(Predicate super T>)' before calling any 'then' method.");
+ }
+
+ // #end BUILD
+
+}
diff --git a/src/main/java/org/codefx/libfx/concurrent/when/package-info.java b/src/main/java/org/codefx/libfx/concurrent/when/package-info.java
new file mode 100644
index 0000000..d339b82
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/concurrent/when/package-info.java
@@ -0,0 +1,11 @@
+/**
+ * With {@link org.codefx.libfx.concurrent.when.ExecuteOnceWhen ExecuteOnceWhen} and
+ * {@link org.codefx.libfx.concurrent.when.ExecuteAlwaysWhen ExecuteAlwaysWhen} this package provides two classes which
+ * help to make sure some action which is triggered by a change on an {@link javafx.beans.value.ObservableValue
+ * ObservableValue} gets executed under threading. Refer to the two classes for a detailed description.
+ *
+ * Instances of those classes can be built with {@link org.codefx.libfx.concurrent.when.ExecuteWhen ExecuteWhen}.
+ *
+ * @see org.codefx.libfx.concurrent.when.ExecuteWhen ExecuteWhen
+ */
+package org.codefx.libfx.concurrent.when;
\ No newline at end of file
diff --git a/src/main/java/org/codefx/libfx/control/package-info.java b/src/main/java/org/codefx/libfx/control/package-info.java
new file mode 100644
index 0000000..f0a6a20
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/package-info.java
@@ -0,0 +1,6 @@
+/**
+ * This package provides functionality around UI Controls. Subpackages might provide additional functionality for
+ * existing Swing or JavaFX controls, implement new ones or provide other features related to controls.
+ */
+package org.codefx.libfx.control;
+
diff --git a/src/main/java/org/codefx/libfx/control/properties/AbstractControlPropertyListenerHandle.java b/src/main/java/org/codefx/libfx/control/properties/AbstractControlPropertyListenerHandle.java
new file mode 100644
index 0000000..2434a52
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/properties/AbstractControlPropertyListenerHandle.java
@@ -0,0 +1,122 @@
+package org.codefx.libfx.control.properties;
+
+import java.util.Objects;
+
+import javafx.collections.MapChangeListener;
+import javafx.collections.ObservableMap;
+
+/**
+ * Abstract superclass to implementations of {@link ControlPropertyListenerHandle}. Handles all aspects of listening
+ * except the actual processing of the value which is delegated to the implementations.
+ */
+abstract class AbstractControlPropertyListenerHandle implements ControlPropertyListenerHandle {
+
+ // #region FIELDS
+
+ /**
+ * The properties to which the {@link #listener} will be added.
+ */
+ private final ObservableMap properties;
+
+ /**
+ * The key to which the {@link #listener} listens.
+ */
+ private final Object key;
+
+ /**
+ * The listener which will be added to the {@link #properties}.
+ */
+ private final MapChangeListener listener;
+
+ /**
+ * Indicates whether the {@link #listener} is currently attached to the {@link #properties} map.
+ */
+ private boolean attached;
+
+ // #end FIELDS
+
+ // #region CONSTRUCTION
+
+ /**
+ * Creates a new listener handle. Initially detached.
+ *
+ * @param properties
+ * the {@link ObservableMap} holding the properties
+ * @param key
+ * the key to which the listener will listen
+ */
+ protected AbstractControlPropertyListenerHandle(
+ ObservableMap properties, Object key) {
+
+ Objects.requireNonNull(properties, "The argument 'properties' must not be null.");
+ Objects.requireNonNull(key, "The argument 'key' must not be null.");
+
+ this.properties = properties;
+ this.key = key;
+ this.listener = createListener(key);
+ }
+
+ /**
+ * Creates a map listener which checks whether a value was set for the correct key, delegates to
+ * {@link #processValueIfPossible(Object)} if that is so and then removes the key-value-pair from the map.
+ *
+ * @param key
+ * the key to which the listener will listen
+ * @return a {@link MapChangeListener}
+ */
+ private MapChangeListener createListener(Object key) {
+ return change -> {
+ boolean setForCorrectKey = change.wasAdded() && Objects.equals(key, change.getKey());
+ if (setForCorrectKey)
+ processAndRemoveValue(change.getValueAdded());
+ };
+ }
+
+ // #end CONSTRUCTION
+
+ // #region PROCESS VALUE
+
+ /**
+ * Processes the specified value for the {@link #key} before removing the pair from the {@link #properties}
+ *
+ * @param value
+ * the value added to the map
+ */
+ private void processAndRemoveValue(Object value) {
+ processValueIfPossible(value);
+ properties.remove(key);
+ }
+
+ /**
+ * Called when a value was set for the correct key.
+ *
+ * @param value
+ * the value associated with the key
+ * @return whether the value could be processed
+ */
+ protected abstract boolean processValueIfPossible(Object value);
+
+ // #end PROCESS VALUE
+
+ // #region IMPLEMENTATION OF 'ControlPropertyListenerHandle'
+
+ @Override
+ public void attach() {
+ if (attached)
+ return;
+
+ attached = true;
+ properties.addListener(listener);
+ if (properties.containsKey(key))
+ processAndRemoveValue(properties.get(key));
+ }
+
+ @Override
+ public void detach() {
+ attached = false;
+ properties.removeListener(listener);
+ }
+
+ // #end IMPLEMENTATION OF 'ControlPropertyListenerHandle'
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/properties/CastingControlPropertyListenerHandle.java b/src/main/java/org/codefx/libfx/control/properties/CastingControlPropertyListenerHandle.java
new file mode 100644
index 0000000..78a6a26
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/properties/CastingControlPropertyListenerHandle.java
@@ -0,0 +1,57 @@
+package org.codefx.libfx.control.properties;
+
+import java.util.Objects;
+import java.util.function.Consumer;
+
+import javafx.collections.ObservableMap;
+
+/**
+ * Implementation of {@link ControlPropertyListenerHandle} which optimistically casts all values to the expected type.
+ * If that does not work, the {@link ClassCastException} is caught and ignored.
+ *
+ * @param
+ * the type of values which the listener processes
+ */
+final class CastingControlPropertyListenerHandle extends AbstractControlPropertyListenerHandle {
+
+ /**
+ * The user specified processor for values.
+ */
+ private final Consumer super T> valueProcessor;
+
+ /**
+ * Creates a new listener handle. Initially detached.
+ *
+ * @param properties
+ * the {@link ObservableMap} holding the properties
+ * @param key
+ * the key to which the listener will listen
+ * @param valueProcessor
+ * the {@link Consumer} for the key's values
+ */
+ CastingControlPropertyListenerHandle(
+ ObservableMap properties, Object key, Consumer super T> valueProcessor) {
+
+ super(properties, key);
+ Objects.requireNonNull(valueProcessor, "The argument 'valueProcessor' must not be null.");
+
+ this.valueProcessor = valueProcessor;
+ }
+
+ @Override
+ protected boolean processValueIfPossible(Object value) {
+ // give the value to the consumer if it has the correct type
+ try {
+ // note that this cast does nothing except to calm the compiler
+ @SuppressWarnings("unchecked")
+ T convertedValue = (T) value;
+ // this is where the exception might actually be created
+ valueProcessor.accept(convertedValue);
+ return true;
+ } catch (ClassCastException e) {
+ // the value was of the wrong type so it can't be processed by the consumer
+ return false;
+ }
+ }
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/properties/ControlProperties.java b/src/main/java/org/codefx/libfx/control/properties/ControlProperties.java
new file mode 100644
index 0000000..1fa2d4b
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/properties/ControlProperties.java
@@ -0,0 +1,29 @@
+package org.codefx.libfx.control.properties;
+
+import javafx.collections.ObservableMap;
+
+/**
+ * Gives access to a {@link ControlPropertyListenerBuilder}.
+ */
+public class ControlProperties {
+
+ /**
+ * Creates a builder for a {@link ControlPropertyListenerHandle} which observes the specified property map.
+ *
+ * Note that it is often necessary to explicitly specify the type parameter {@code T} like so:
+ *
+ *
+ * ControlProperties.<String> on(...)
+ *
+ *
+ * @param
+ * the type of values which the listener processes
+ * @param properties
+ * the {@link ObservableMap} holding the properties
+ * @return a {@link ControlPropertyListenerBuilder}
+ */
+ public static ControlPropertyListenerBuilder on(ObservableMap properties) {
+ return ControlPropertyListenerBuilder. on(properties);
+ }
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerBuilder.java b/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerBuilder.java
new file mode 100644
index 0000000..4b4a1d9
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerBuilder.java
@@ -0,0 +1,185 @@
+package org.codefx.libfx.control.properties;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import javafx.collections.ObservableMap;
+
+/**
+ * A builder for a {@code ControlPropertyListener}. This is no type on its own as explained in
+ * {@link ControlPropertyListenerHandle}. Such a handle is returned by this builder.
+ *
+ * It is best created by calling {@link ControlProperties#on(ObservableMap)} with the control's property map as an
+ * argument. It is necessary to set a key (with {@link #forKey(Object)}) and a processor function for the value (with
+ * {@link #processValue(Consumer)}) before calling {@link #buildDetached()}.
+ *
+ * Specifying the value's type with {@link #forValueType(Class)} is optional. If it is done, the built listener will use
+ * it to check the type of the value before casting it to the type accepted by the value processor. If those types do
+ * not match, this prevents {@link ClassCastException} (which would otherwise be caught and silently ignored). If that
+ * case occurs frequently, specifying the type to allow the check will improve performance considerably.
+ *
+ * Example
+ *
+ * A typical use looks like this:
+ *
+ *
+ * ControlProperties.<Boolean> on(control.getProperties())
+ * .forKey("visible")
+ * .processValue(this::setVisibility)
+ * .buildDetached();
+ *
+ *
+ * @param
+ * the type of values which the listener processes
+ */
+public class ControlPropertyListenerBuilder {
+
+ // #region FIELDS
+
+ /**
+ * The properties which will be observed.
+ */
+ private final ObservableMap properties;
+
+ /**
+ * The key to which the listener will listen; must no be null by the time {@link #buildDetached()} is called.
+ */
+ private Object key;
+
+ /**
+ * The processor of the key's values; must no be null by the time {@link #buildDetached()} is called.
+ */
+ private Consumer super T> valueProcessor;
+
+ /**
+ * The type of value which the listener processes
+ */
+ private Optional> valueType;
+
+ // #end FIELDS
+
+ // #region CONSTRUCTION & SETTING VALUES
+
+ /**
+ * Creates a new builder.
+ *
+ * @param properties
+ * the properties which will be observed by the built listener
+ */
+ private ControlPropertyListenerBuilder(ObservableMap properties) {
+ Objects.requireNonNull(properties, "The argument 'properties' must not be null.");
+ this.properties = properties;
+ this.valueType = Optional.empty();
+ }
+
+ /**
+ * Creates a builder for a {@link ControlPropertyListenerHandle} which observes the specified property map.
+ *
+ * Note that it is often necessary to explicitly specify the type parameter {@code T} like so:
+ *
+ *
+ * ControlProperties.<String> on(...)
+ *
+ *
+ * @param
+ * the type of values which the listener processes
+ * @param properties
+ * the {@link ObservableMap} holding the properties
+ * @return a {@link ControlPropertyListenerBuilder}
+ */
+ public static ControlPropertyListenerBuilder on(ObservableMap properties) {
+ return new ControlPropertyListenerBuilder(properties);
+ }
+
+ /**
+ * Sets the key. This must be called before {@link #buildDetached()}.
+ *
+ * @param key
+ * the key the built listener will observe
+ * @return this builder instance for fluent API
+ */
+ public ControlPropertyListenerBuilder forKey(Object key) {
+ Objects.requireNonNull(key, "The argument 'key' must not be null.");
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * Sets the type of the values which the built listener will process. Used to type check before calling the
+ * {@link #processValue(Consumer) valueProcessor}.
+ *
+ * This type is optional. See the class comment on {@link ControlPropertyListenerBuilder this builder} for details.
+ *
+ * @param valueType
+ * the type of values the built listener will process
+ * @return this builder instance for fluent API
+ */
+ public ControlPropertyListenerBuilder forValueType(Class valueType) {
+ Objects.requireNonNull(valueType, "The argument 'valueType' must not be null.");
+ this.valueType = Optional.of(valueType);
+ return this;
+ }
+
+ /**
+ * Sets the processor for the key's values. This must be called before {@link #buildAttached()}.
+ *
+ * @param valueProcessor
+ * the {@link Consumer} for the key's values
+ * @return this builder instance for fluent API
+ */
+ public ControlPropertyListenerBuilder processValue(Consumer super T> valueProcessor) {
+ Objects.requireNonNull(valueProcessor, "The argument 'valueProcessor' must not be null.");
+ this.valueProcessor = valueProcessor;
+ return this;
+ }
+
+ // #end CONSTRUCTION & SETTING VALUES
+
+ // #region BUILD
+
+ /**
+ * Creates a new property listener according to the arguments specified before and
+ * {@link ControlPropertyListenerHandle#attach() attaches} it.
+ *
+ * @return a {@link ControlPropertyListenerHandle}; initially attached
+ * @see #buildDetached()
+ */
+ public ControlPropertyListenerHandle buildAttached() {
+ ControlPropertyListenerHandle listener = buildDetached();
+ listener.attach();
+ return listener;
+ }
+
+ /**
+ * Creates a new property listener according to the arguments specified before.
+ *
+ * Note that this builder is not yet attached to the map! This can be done by calling
+ * {@link ControlPropertyListenerHandle#attach() attach()} on the returned instance.
+ *
+ * @return a {@link ControlPropertyListenerHandle}; initially detached
+ * @see #buildAttached()
+ */
+ public ControlPropertyListenerHandle buildDetached() {
+ checkFields();
+
+ if (valueType.isPresent())
+ return new TypeCheckingControlPropertyListenerHandle(properties, key, valueType.get(), valueProcessor);
+ else
+ return new CastingControlPropertyListenerHandle(properties, key, valueProcessor);
+ }
+
+ /**
+ * Checks whether the fields are valid so they can be used to {@link #buildDetached() build} a listener.
+ */
+ private void checkFields() {
+ if (key == null)
+ throw new IllegalStateException("Set a key with 'forKey' before calling 'build'.");
+ if (valueProcessor == null)
+ throw new IllegalStateException("Set a value processor with 'processValue' before calling 'build'.");
+ // value type is optional, so no checks
+ }
+
+ // #end BUILD
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerHandle.java b/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerHandle.java
new file mode 100644
index 0000000..6c6f066
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/properties/ControlPropertyListenerHandle.java
@@ -0,0 +1,44 @@
+package org.codefx.libfx.control.properties;
+
+import org.codefx.libfx.listener.handle.ListenerHandle;
+
+/**
+ *
+ * This is a {@link ListenerHandle handle} on a {@code ControlPropertyListener}, which can be used to {@link #attach()}
+ * and {@link #detach()} it. The {@code ControlPropertyListener} is no type on its own so it is described here.
+ *
+ * ControlPropertyListener
+ *
+ * A control property listener listens to the changes in a Control's
+ * {@link javafx.scene.control.Control#getProperties() propertyMap}. It is created to listen for a specific key and
+ * hands all new values for that key to a value processor (a {@link java.util.function.Consumer Consumer})..
+ *
+ * Even though the property map's value type is {@code Object}, the processor might limit the value's type to any other
+ * class. If the actual value can not be cast to that type, it is silently ignored.
+ *
+ * Regardless of whether a value could be cast and processed or not, it will be removed from the map. So if the same
+ * value is set repeatedly, the specified value processor is called every time.
+ *
+ * ControlPropertyListenerHandle
+ *
+ * Listener handles are not thread-safe. See {@link ListenerHandle} for details. Additionally, a new value might be
+ * processed twice if inserted into a map by another thread while {@link #attach()} is executed. This behavior should
+ * not be relied upon and might change (i.e. be fixed) in the future.
+ *
+ * A listener handle is best created with the {@link ControlPropertyListenerBuilder}.
+ */
+public interface ControlPropertyListenerHandle extends ListenerHandle {
+
+ /**
+ * Attaches/adds the listener to the properties map. This immediately processes the key if it is present.
+ */
+ @Override
+ void attach();
+
+ /**
+ * Detaches/removes the listener from the properties map.
+ */
+ @Override
+ void detach();
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/properties/TypeCheckingControlPropertyListenerHandle.java b/src/main/java/org/codefx/libfx/control/properties/TypeCheckingControlPropertyListenerHandle.java
new file mode 100644
index 0000000..a8241db
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/properties/TypeCheckingControlPropertyListenerHandle.java
@@ -0,0 +1,63 @@
+package org.codefx.libfx.control.properties;
+
+import java.util.Objects;
+import java.util.function.Consumer;
+
+import javafx.collections.ObservableMap;
+
+/**
+ * A {@link ControlPropertyListenerHandle} which uses a {@link Class} instance specified during construction to check
+ * whether a value is of the correct type.
+ *
+ * @param
+ * the type of values which the listener processes
+ */
+final class TypeCheckingControlPropertyListenerHandle extends AbstractControlPropertyListenerHandle {
+
+ /**
+ * The type of values which the listener processes.
+ */
+ private final Class valueType;
+
+ /**
+ * The user specified processor for values.
+ */
+ private final Consumer super T> valueProcessor;
+
+ /**
+ * Creates a listener handle. Initially detached.
+ *
+ * @param properties
+ * the {@link ObservableMap} holding the properties
+ * @param key
+ * the key to which the listener will listen
+ * @param valueType
+ * the type of values which the listener processes
+ * @param valueProcessor
+ * the {@link Consumer} for the key's values
+ */
+ TypeCheckingControlPropertyListenerHandle(
+ ObservableMap properties, Object key, Class valueType, Consumer super T> valueProcessor) {
+
+ super(properties, key);
+ Objects.requireNonNull(valueProcessor, "The argument 'valueProcessor' must not be null.");
+ Objects.requireNonNull(valueType, "The argument 'valueType' must not be null.");
+
+ this.valueType = valueType;
+ this.valueProcessor = valueProcessor;
+ }
+
+ @Override
+ protected boolean processValueIfPossible(Object value) {
+ boolean valueHasCorrectType = valueType.isInstance(value);
+ if (valueHasCorrectType) {
+ // due to the check above the cast always succeeds
+ @SuppressWarnings("unchecked")
+ T convertedValue = (T) value;
+ valueProcessor.accept(convertedValue);
+ return true;
+ } else
+ return false;
+ }
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/properties/package-info.java b/src/main/java/org/codefx/libfx/control/properties/package-info.java
new file mode 100644
index 0000000..2bd979e
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/properties/package-info.java
@@ -0,0 +1,36 @@
+/**
+ *
+ * This package provides functionality to make using a {@link javafx.scene.control.Control Control}'s
+ * {@link javafx.scene.control.Control#getProperties() propertyMap} easier. As such its main use will be to creators of
+ * controls.
+ *
+ * Listening to the Property Map
+ *
+ * In order to use the property map, a control has to create a listener which does these things:
+ *
+ * identify the correct key
+ * check whether the value is of the correct type and cast it
+ * process the value
+ * remove the value so when the same value is added again, the listener notices that
+ *
+ * While implementing such a listener is not difficult, some details have to be considered. This makes the code a little
+ * lengthy and hinders readability while at the same time repeating the same pattern over and over.
+ * ControlPropertyListener
+ *
+ * This package provides usability functions to create such a listener in a concise and readable way (this code would be
+ * inside a control):
+ *
+ *
+ * ControlProperties.on(getProperties())
+ * .forKey("SomeKey")
+ * .processValue(valueString -> System.out.println(valueString))
+ * .buildAndAttach();
+ *
+ * It returns an instance of {@link org.codefx.libfx.control.properties.ControlPropertyListenerHandle
+ * ControlPropertyListenerHandle} which can be used to easily detach and reattach the listener.
+ *
+ * @see org.codefx.libfx.control.properties.ControlPropertyListenerHandle ControlPropertyListener
+ * @see org.codefx.libfx.control.properties.ControlPropertyListenerBuilder ControlPropertyListenerBuilder
+ */
+package org.codefx.libfx.control.properties;
+
diff --git a/src/main/java/org/codefx/libfx/control/webview/DefaultWebViewHyperlinkListenerHandle.java b/src/main/java/org/codefx/libfx/control/webview/DefaultWebViewHyperlinkListenerHandle.java
new file mode 100644
index 0000000..b56374c
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/webview/DefaultWebViewHyperlinkListenerHandle.java
@@ -0,0 +1,282 @@
+package org.codefx.libfx.control.webview;
+
+import java.util.Optional;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import javafx.application.Platform;
+import javafx.beans.value.ObservableValue;
+import javafx.concurrent.Worker.State;
+import javafx.scene.web.WebView;
+
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkEvent.EventType;
+
+import org.codefx.libfx.concurrent.when.ExecuteAlwaysWhen;
+import org.codefx.libfx.concurrent.when.ExecuteWhen;
+import org.codefx.libfx.dom.DomEventConverter;
+import org.codefx.libfx.dom.DomEventType;
+import org.w3c.dom.NodeList;
+import org.w3c.dom.events.Event;
+import org.w3c.dom.events.EventListener;
+import org.w3c.dom.events.EventTarget;
+
+/**
+ * A default implementation of {@link WebViewHyperlinkListenerHandle} which acts on the {@link WebView} and
+ * {@link WebViewHyperlinkListener} specified during construction.
+ */
+class DefaultWebViewHyperlinkListenerHandle implements WebViewHyperlinkListenerHandle {
+
+ // Inspired by :
+ // - http://stackoverflow.com/q/17555937 -
+ // - http://blogs.kiyut.com/tonny/2013/07/30/javafx-webview-addhyperlinklistener/
+
+ /*
+ * Many type names do not allow to easily recognize whether they come from the DOM- or the JavaFX-packages. To make
+ * it easier, all instances of org.w3c.dom classes carry a 'dom'-prefix.
+ */
+
+ // #region FIELDS
+
+ /**
+ * The {@link WebView} to which the {@link #domEventListener} will be attached.
+ */
+ private final WebView webView;
+
+ /**
+ * The {@link WebViewHyperlinkListener} which will be called by {@link #domEventListener} when an event occurs.
+ */
+ private final WebViewHyperlinkListener eventListener;
+
+ /**
+ * The filter for events by their {@link EventType}. If the filter is empty, all events will be processed. Otherwise
+ * only events of the present type will be processed.
+ */
+ private final Optional eventTypeFilter;
+
+ /**
+ * The DOM-{@link EventListener} which will be attached to the {@link #webView}.
+ */
+ private final EventListener domEventListener;
+
+ /**
+ * Converts the observed DOM {@link Event}s to {@link HyperlinkEvent}s.
+ */
+ private final DomEventConverter eventConverter;
+
+ /**
+ * Executes {@link #attachListenerInApplicationThread()} each time the web view's load worker changes its state to
+ * {@link State#SUCCEEDED SUCCEEDED}.
+ *
+ * The executer is only present while the listener is {@link #attached}.
+ */
+ private Optional> attachWhenLoadSucceeds;
+
+ /**
+ * Indicates whether the listener is currently attached.
+ */
+ private boolean attached;
+
+ // #end FIELDS
+
+ /**
+ * Creates a new listener handle for the specified arguments. The listener is not attached to the web view.
+ *
+ * @param webView
+ * the {@link WebView} to which the {@code eventListener} will be attached
+ * @param eventListener
+ * the {@link WebViewHyperlinkListener} which will be attached to the {@code webView}
+ * @param eventTypeFilter
+ * the filter for events by their {@link EventType}
+ * @param eventConverter
+ * the converter for DOM {@link Event}s
+ */
+ public DefaultWebViewHyperlinkListenerHandle(
+ WebView webView, WebViewHyperlinkListener eventListener, Optional eventTypeFilter,
+ DomEventConverter eventConverter) {
+
+ this.webView = webView;
+ this.eventListener = eventListener;
+ this.eventTypeFilter = eventTypeFilter;
+ this.eventConverter = eventConverter;
+
+ domEventListener = this::callHyperlinkListenerWithEvent;
+ }
+
+ // #region ATTACH
+
+ @Override
+ public void attach() {
+ if (attached)
+ return;
+
+ attached = true;
+ if (Platform.isFxApplicationThread())
+ attachInApplicationThreadEachTimeLoadSucceeds();
+ else
+ Platform.runLater(() -> attachInApplicationThreadEachTimeLoadSucceeds());
+ }
+
+ /**
+ * Attaches the {@link #domEventListener} to the {@link #webView} every time the {@code webView} successfully loaded
+ * a page.
+ *
+ * Must be called in JavaFX application thread.
+ */
+ private void attachInApplicationThreadEachTimeLoadSucceeds() {
+ ObservableValue webWorkerState = webView.getEngine().getLoadWorker().stateProperty();
+
+ ExecuteAlwaysWhen attachWhenLoadSucceeds = ExecuteWhen
+ .on(webWorkerState)
+ .when(state -> state == State.SUCCEEDED)
+ .thenAlways(state -> attachListenerInApplicationThread());
+ this.attachWhenLoadSucceeds = Optional.of(attachWhenLoadSucceeds);
+
+ attachWhenLoadSucceeds.executeWhen();
+ }
+
+ /**
+ * Attaches the {@link #domEventListener} to the {@link #webView}.
+ *
+ * Must be called in JavaFX application thread.
+ */
+ private void attachListenerInApplicationThread() {
+ BiConsumer addListener =
+ (eventTarget, eventType) -> eventTarget.addEventListener(eventType, domEventListener, false);
+ onEachLinkForEachManagedEventType(addListener);
+ }
+
+ // #end ATTACH
+
+ // #region DETACH
+
+ @Override
+ public void detach() {
+ if (!attached)
+ return;
+
+ attached = false;
+ if (Platform.isFxApplicationThread())
+ detachInApplicationThread();
+ else
+ Platform.runLater(() -> detachInApplicationThread());
+ }
+
+ /**
+ * Detaches the {@link #domEventListener} from the {@link #webView} and cancels and resets
+ * {@link #attachWhenLoadSucceeds}.
+ *
+ * Must be called in JavaFX application thread.
+ */
+ private void detachInApplicationThread() {
+ attachWhenLoadSucceeds.ifPresent(attachWhen -> attachWhen.cancel());
+ attachWhenLoadSucceeds = Optional.empty();
+
+ // it suffices to remove the listener if the worker state is on SUCCEEDED;
+ // because when the view is currently loading, the canceled 'attachWhen' will not re-add the listener
+ State webWorkerState = webView.getEngine().getLoadWorker().getState();
+ if (webWorkerState == State.SUCCEEDED) {
+ BiConsumer removeListener =
+ (eventTarget, eventType) -> eventTarget.removeEventListener(eventType, domEventListener, false);
+ onEachLinkForEachManagedEventType(removeListener);
+ }
+ }
+
+ // #end DETACH
+
+ // #region COMMON MANAGEMENT METHODS
+
+ /**
+ * Executes the specified function on each link in the {@link #webView}'s current document for each
+ * {@link DomEventType} for which {@link #manageListenerForEventType(DomEventType)} returns true.
+ *
+ * Must be called in JavaFX application thread.
+ *
+ * @param manageListener
+ * a {@link BiConsumer} which acts on a link and a DOM event type
+ */
+ private void onEachLinkForEachManagedEventType(BiConsumer manageListener) {
+ NodeList domNodeList = webView.getEngine().getDocument().getElementsByTagName("a");
+ for (int i = 0; i < domNodeList.getLength(); i++) {
+ EventTarget domTarget = (EventTarget) domNodeList.item(i);
+ onLinkForEachManagedEventType(domTarget, manageListener);
+ }
+ }
+
+ /**
+ * Executes the specified function on the specified link for each {@link DomEventType} for which
+ * {@link #manageListenerForEventType(DomEventType)} returns true.
+ *
+ * Must be called in JavaFX application thread.
+ *
+ * @param link
+ * The {@link EventTarget} with which {@code manageListener} will be called
+ * @param manageListener
+ * a {@link BiConsumer} which acts on a link and a DOM event type
+ */
+ private void onLinkForEachManagedEventType(EventTarget link, BiConsumer manageListener) {
+ Consumer manageListenerForType =
+ domEventType -> manageListener.accept(link, domEventType.getDomName());
+ Stream.of(DomEventType.values())
+ .filter(this::manageListenerForEventType)
+ .forEach(manageListenerForType);
+ }
+
+ /**
+ * Indicates whether a listener must be added for the specified DOM event type and the {@link #eventTypeFilter}.
+ *
+ * @param domEventType
+ * the {@link DomEventType} for which a listener might be added
+ * @return true if the DOM event type has a representation as an hyperlink event type and is not filtered out; false
+ * otherwise
+ */
+ private boolean manageListenerForEventType(DomEventType domEventType) {
+ boolean domEventTypeHasRepresentation = domEventType.toHyperlinkEventType().isPresent();
+ if (!domEventTypeHasRepresentation)
+ return false;
+
+ boolean filterOn = eventTypeFilter.isPresent();
+ if (!filterOn)
+ return true;
+
+ return domEventType.toHyperlinkEventType().get() == eventTypeFilter.get();
+ }
+
+ // #end COMMON MANAGEMENT METHODS
+
+ // #region PROCESS EVENT
+
+ /**
+ * Converts the specified {@code domEvent} into a {@link HyperlinkEvent} and calls the {@link #eventListener} with
+ * it.
+ *
+ * @param domEvent
+ * the DOM-{@link Event}
+ */
+ private void callHyperlinkListenerWithEvent(Event domEvent) {
+ boolean canNotConvertEvent = !eventConverter.canConvertToHyperlinkEvent(domEvent);
+ if (canNotConvertEvent)
+ return;
+
+ HyperlinkEvent event = eventConverter.convertToHyperlinkEvent(domEvent, webView);
+ boolean cancel = eventListener.hyperlinkUpdate(event);
+ cancel(domEvent, cancel);
+ }
+
+ /**
+ * Cancels the specified event if it is cancelable and cancellation is indicated by the specified flag.
+ *
+ * @param domEvent
+ * the DOM-{@link Event} to be canceled
+ * @param cancel
+ * indicates whether the event should be canceled
+ */
+ private static void cancel(Event domEvent, boolean cancel) {
+ if (domEvent.getCancelable() && cancel)
+ domEvent.preventDefault();
+ }
+
+ // #end PROCESS EVENT
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListener.java b/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListener.java
new file mode 100644
index 0000000..01e8876
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListener.java
@@ -0,0 +1,63 @@
+package org.codefx.libfx.control.webview;
+
+import java.util.function.Function;
+
+import javafx.scene.web.WebEngine;
+import javafx.scene.web.WebView;
+
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkListener;
+
+/**
+ * A listener to {@link HyperlinkEvent}s which are dispatched by a {@link WebView}.
+ *
+ * Very similar do the {@link HyperlinkListener} but it can cancel the further processing of events by the
+ * {@link WebEngine}. This does not extent to other listeners of this type to the same {@code WebView} - these are
+ * always called.
+ */
+public interface WebViewHyperlinkListener {
+
+ /**
+ * Adapts the specified (Swing) hyperlink listener to a web view hyperlink listener.
+ *
+ * @param listener
+ * the {@link HyperlinkListener} to adapt
+ * @param cancel
+ * a {@link Function} which checks for the specified event whether it should be canceled
+ * @return a {@link WebViewHyperlinkListener}
+ */
+ static WebViewHyperlinkListener fromHyperlinkListener(HyperlinkListener listener,
+ Function cancel) {
+ return event -> {
+ listener.hyperlinkUpdate(event);
+ return cancel.apply(event);
+ };
+ }
+
+ /**
+ * Adapts the specified (Swing) hyperlink listener to a web view hyperlink listener.
+ *
+ * @param listener
+ * the {@link HyperlinkListener} to adapt
+ * @param cancel
+ * whether the created listener should cancel every event
+ * @return a {@link WebViewHyperlinkListener}
+ */
+ static WebViewHyperlinkListener fromHyperlinkListener(HyperlinkListener listener, boolean cancel) {
+ return event -> {
+ listener.hyperlinkUpdate(event);
+ return cancel;
+ };
+ }
+
+ /**
+ * Called when a hypertext link is updated.
+ *
+ * @param event
+ * the event responsible for the update
+ * @return whether the event should be canceled; if more than one listener is called on a single event, their return
+ * values are "ored".
+ */
+ boolean hyperlinkUpdate(HyperlinkEvent event);
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerHandle.java b/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerHandle.java
new file mode 100644
index 0000000..273c758
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/webview/WebViewHyperlinkListenerHandle.java
@@ -0,0 +1,40 @@
+package org.codefx.libfx.control.webview;
+
+import javafx.scene.web.WebEngine;
+import javafx.scene.web.WebView;
+
+import org.codefx.libfx.listener.handle.ListenerHandle;
+
+/**
+ * A {@link ListenerHandle} for a {@link WebViewHyperlinkListener}.
+ *
+ * @see ListenerHandle
+ * @see WebViewHyperlinkListener
+ */
+public interface WebViewHyperlinkListenerHandle extends ListenerHandle {
+
+ /**
+ * Attaches/adds the {@link WebViewHyperlinkListener} to the {@link WebView}.
+ *
+ * This method can be called from any thread and regardless of the state of the {@code WebView}'s
+ * {@link WebEngine#getLoadWorker() loadWorker}. If it is not called on the FX Application Thread, the listener will
+ * be added at some unspecified time in the future. If the {@code loadWorker} is currently loading, the listener is
+ * attached as soon as it is done.
+ *
+ * @see ListenerHandle#attach()
+ */
+ @Override
+ void attach();
+
+ /**
+ * Detaches/removes the {@link WebViewHyperlinkListener} from the {@link WebView}.
+ *
+ * This method can be called from any thread and regardless of the state of the {@code WebView}'s
+ * {@link WebEngine#getLoadWorker() loadWorker}.
+ *
+ * @see ListenerHandle#detach()
+ */
+ @Override
+ void detach();
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/webview/WebViews.java b/src/main/java/org/codefx/libfx/control/webview/WebViews.java
new file mode 100644
index 0000000..de507a2
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/webview/WebViews.java
@@ -0,0 +1,241 @@
+package org.codefx.libfx.control.webview;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import javafx.scene.web.WebView;
+
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkEvent.EventType;
+
+import org.codefx.libfx.dom.DomEventConverter;
+import org.codefx.libfx.dom.DomEventType;
+import org.codefx.libfx.dom.StaticDomEventConverter;
+import org.w3c.dom.events.Event;
+
+/**
+ * Usability methods for the {@link WebView}.
+ */
+public final class WebViews {
+
+ /**
+ * Private constructor for utility class.
+ */
+ private WebViews() {
+ // nothing to do
+ }
+
+ // #region HYPERLINK LISTENERS
+
+ // create listener handles
+
+ /**
+ * Creates a handle with which the specified listener can be {@link WebViewHyperlinkListenerHandle#attach()
+ * attached} to the specified web view.
+ *
+ * Once attached, the listener will be called on any event on an hyperlink (i.e. any element with tag name "a")
+ * which can be represented as a {@link HyperlinkEvent}. This is the case on {@link DomEventType#MOUSE_ENTER
+ * MOUSE_ENTER}, {@link DomEventType#MOUSE_LEAVE MOUSE_LEAVE} and {@link DomEventType#CLICK CLICK}.
+ *
+ * @param webView
+ * the {@link WebView} to which the listener will be added
+ * @param listener
+ * the {@link WebViewHyperlinkListener} to add to the web view
+ * @return a handle on the created listener which allows to attach and detach it; initially detached
+ * @see #addHyperlinkListener(WebView, WebViewHyperlinkListener)
+ */
+ public static WebViewHyperlinkListenerHandle createHyperlinkListenerHandle(
+ WebView webView, WebViewHyperlinkListener listener) {
+
+ return addHyperlinkListenerDetached(webView, listener, Optional.empty());
+ }
+
+ /**
+ * Creates a handle with which the specified listener can be {@link WebViewHyperlinkListenerHandle#attach()
+ * attached} to the specified web view.
+ *
+ * Once attached, the listener will be called on any event on an hyperlink (i.e. any element with tag name "a")
+ * which can be represented as a {@link HyperlinkEvent} with the specified event type. See
+ * {@link DomEventType#toHyperlinkEventType()} for the conversion of event types.
+ *
+ * @param webView
+ * the {@link WebView} to which the listener will be added
+ * @param listener
+ * the {@link WebViewHyperlinkListener} to add to the web view
+ * @param eventType
+ * the {@link EventType} of all events passed to the listener
+ * @return a handle on the created listener which allows to attach and detach it; initially detached
+ * @see #addHyperlinkListener(WebView, WebViewHyperlinkListener, HyperlinkEvent.EventType)
+ */
+ public static WebViewHyperlinkListenerHandle createHyperlinkListenerHandle(
+ WebView webView, WebViewHyperlinkListener listener, EventType eventType) {
+
+ Objects.requireNonNull(eventType, "The argument 'eventType' must not be null.");
+ return addHyperlinkListenerDetached(webView, listener, Optional.of(eventType));
+ }
+
+ // create and attach listener handles
+
+ /**
+ * {@link #createHyperlinkListenerHandle(WebView, WebViewHyperlinkListener) Creates} a listener handle and
+ * immediately {@link WebViewHyperlinkListenerHandle#attach() attaches} it.
+ *
+ * @param webView
+ * the {@link WebView} to which the listener will be added
+ * @param listener
+ * the {@link WebViewHyperlinkListener} to add to the web view
+ * @return a handle on the created listener which allows to attach and detach it; initially attached
+ * @see #createHyperlinkListenerHandle(WebView, WebViewHyperlinkListener)
+ */
+ public static WebViewHyperlinkListenerHandle addHyperlinkListener(
+ WebView webView, WebViewHyperlinkListener listener) {
+
+ WebViewHyperlinkListenerHandle listenerHandle = addHyperlinkListenerDetached(webView, listener,
+ Optional.empty());
+ listenerHandle.attach();
+ return listenerHandle;
+ }
+
+ /**
+ * {@link #createHyperlinkListenerHandle(WebView, WebViewHyperlinkListener, HyperlinkEvent.EventType) Creates} a
+ * listener handle and immediately {@link WebViewHyperlinkListenerHandle#attach() attaches} it.
+ *
+ * @param webView
+ * the {@link WebView} to which the listener will be added
+ * @param listener
+ * the {@link WebViewHyperlinkListener} to add to the web view
+ * @param eventType
+ * the {@link EventType} of all events passed to the listener
+ * @return a handle on the created listener which allows to attach and detach it; initially attached
+ * @see #createHyperlinkListenerHandle(WebView, WebViewHyperlinkListener, HyperlinkEvent.EventType)
+ */
+ public static WebViewHyperlinkListenerHandle addHyperlinkListener(
+ WebView webView, WebViewHyperlinkListener listener, EventType eventType) {
+
+ Objects.requireNonNull(eventType, "The argument 'eventType' must not be null.");
+ WebViewHyperlinkListenerHandle listenerHandle = addHyperlinkListenerDetached(webView, listener,
+ Optional.of(eventType));
+ listenerHandle.attach();
+ return listenerHandle;
+ }
+
+ /**
+ * Adds the specified listener to the specified WebView.
+ *
+ * If necessary this method switches to the FX application thread.
+ *
+ * @param webView
+ * the {@link WebView} to which the listener will be added
+ * @param listener
+ * the {@link WebViewHyperlinkListener} to add to the web view
+ * @param eventTypeFilter
+ * the {@link EventType} of all events passed to the listener; {@link Optional#empty()} means all events
+ * are passed
+ * @return a handle on the created listener which allows to attach and detach it
+ */
+ private static WebViewHyperlinkListenerHandle addHyperlinkListenerDetached(
+ WebView webView, WebViewHyperlinkListener listener, Optional eventTypeFilter) {
+
+ Objects.requireNonNull(webView, "The argument 'webView' must not be null.");
+ Objects.requireNonNull(listener, "The argument 'listener' must not be null.");
+ Objects.requireNonNull(eventTypeFilter, "The argument 'eventTypeFilter' must not be null.");
+
+ return new DefaultWebViewHyperlinkListenerHandle(
+ webView, listener, eventTypeFilter, new DomEventConverter());
+ }
+
+ // #end HYPERLINK LISTENERS
+
+ // #region EVENTS
+
+ /**
+ * Indicates whether the specified DOM event can be converted to a {@link HyperlinkEvent}.
+ *
+ * @param domEvent
+ * the DOM-{@link Event}
+ * @return true if the event's {@link Event#getType() type} has an equivalent {@link EventType EventType}
+ */
+ public static boolean canConvertToHyperlinkEvent(Event domEvent) {
+ return StaticDomEventConverter.canConvertToHyperlinkEvent(domEvent);
+ }
+
+ /**
+ * Converts the specified DOM event to a hyperlink event.
+ *
+ * @param domEvent
+ * the DOM-{@link Event} from which the {@link HyperlinkEvent} will be created
+ * @param source
+ * the source of the {@code domEvent}
+ * @return a {@link HyperlinkEvent}
+ * @throws IllegalArgumentException
+ * if the specified event can not be converted to a hyperlink event; this is the case if
+ * {@link #canConvertToHyperlinkEvent(Event)} returns false
+ */
+ public static HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object source)
+ throws IllegalArgumentException {
+
+ return StaticDomEventConverter.convertToHyperlinkEvent(domEvent, source);
+ }
+
+ /**
+ * Returns a string representation of the specified event.
+ *
+ * @param event
+ * the {@link HyperlinkEvent} which will be converted to a string
+ * @return a string representation of the event
+ */
+ public static String hyperlinkEventToString(HyperlinkEvent event) {
+ Objects.requireNonNull(event, "The parameter 'event' must not be null.");
+
+ return "HyperlinkEvent ["
+ + "type: "
+ + event.getEventType()
+ + "; "
+ + "URL (description): "
+ + event.getURL()
+ + " ("
+ + event.getDescription()
+ + "); "
+ + "source: "
+ + event.getSource()
+ + "; "
+ + "source element: "
+ + event.getSourceElement()
+ + "]";
+ }
+
+ // #end HYPERLINK EVENTS
+
+ // #region DOM EVENTS
+
+ /**
+ * Returns a string representation of the specified event.
+ *
+ * @param event
+ * the DOM-{@link Event} which will be converted to a string
+ * @return a string representation of the event
+ */
+ public static String domEventToString(Event event) {
+ Objects.requireNonNull(event, "The parameter 'event' must not be null.");
+
+ return "DOM-Event ["
+ + "target: "
+ + event.getTarget()
+ + "; "
+ + "type: "
+ + event.getType()
+ + "; "
+ + "time stamp: "
+ + event.getTimeStamp()
+ + "; "
+ + "bubbles: "
+ + event.getBubbles()
+ + "; "
+ + "cancelable: "
+ + event.getCancelable()
+ + "]";
+ }
+
+ // #end DOM EVENTS
+
+}
diff --git a/src/main/java/org/codefx/libfx/control/webview/package-info.java b/src/main/java/org/codefx/libfx/control/webview/package-info.java
new file mode 100644
index 0000000..c1d402c
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/control/webview/package-info.java
@@ -0,0 +1,24 @@
+/**
+ *
+ * This package provides functionality around JavaFX' {@link javafx.scene.web.WebView WebView}. All of it can be
+ * accessed via the utility class {@link org.codefx.libfx.control.webview.WebViews WebViews}.
+ *
+ * Hyperlink Listener
+ *
+ * The {@code WebView} provides no pleasant way to add an equivalent of Swing's
+ * {@link javax.swing.event.HyperlinkListener HyperlinkListener}.
+ *
+ * This can now be done by implementing a {@link org.codefx.libfx.control.webview.WebViewHyperlinkListener
+ * WebViewHyperlinkListener}, which is very similar to Swing's {@code HyperlinkListener} and also processes
+ * {@link javax.swing.event.HyperlinkEvent HyperlinkEvents}. Together with a {@code WebView} and optionally an event
+ * filter it can be handed to {@code WebViews}'
+ * {@link org.codefx.libfx.control.webview.WebViews#addHyperlinkListener(javafx.scene.web.WebView, WebViewHyperlinkListener)
+ * addHyperlinkListener} method.
+ *
+ * Adding listeners returns a {@link org.codefx.libfx.control.webview.WebViewHyperlinkListenerHandle
+ * WebViewHyperlinkListenerHandle} which can be used to easily attach and detach the listener.
+ *
+ * @see org.codefx.libfx.control.webview.WebViews WebViews
+ */
+package org.codefx.libfx.control.webview;
+
diff --git a/src/main/java/org/codefx/libfx/dom/DomEventConverter.java b/src/main/java/org/codefx/libfx/dom/DomEventConverter.java
new file mode 100644
index 0000000..57fe3f1
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/dom/DomEventConverter.java
@@ -0,0 +1,63 @@
+package org.codefx.libfx.dom;
+
+import java.util.Objects;
+
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkEvent.EventType;
+
+import org.w3c.dom.events.Event;
+
+/**
+ * Converts {@link Event DOM Events} to Swing's {@link HyperlinkEvent HyperlinkEvents}.
+ *
+ * This class is thread-safe, i.e. the provided methods can be called from different threads and concurrent executions
+ * do not interfere with each other.
+ */
+public final class DomEventConverter {
+
+ /**
+ * Indicates whether the specified DOM event can be converted to a {@link HyperlinkEvent}.
+ *
+ * @param domEvent
+ * the DOM-{@link Event}
+ * @return true if the event's {@link Event#getType() type} has an equivalent {@link EventType EventType}
+ */
+ @SuppressWarnings("static-method")
+ public boolean canConvertToHyperlinkEvent(Event domEvent) {
+ Objects.requireNonNull(domEvent, "The argument 'domEvent' must not be null.");
+
+ Object source = "the source does not matter for this call";
+ SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source);
+ return converter.canConvert();
+ }
+
+ /**
+ * Converts the specified DOM event to a hyperlink event.
+ *
+ * @param domEvent
+ * the DOM-{@link Event} from which the {@link HyperlinkEvent} will be created
+ * @param source
+ * the source of the {@code domEvent}
+ * @return a {@link HyperlinkEvent} with the following properties:
+ *
+ * {@link HyperlinkEvent#getEventType() getEventType()} returns the {@link EventType} corresponding to
+ * the domEvent's type as defined by {@link DomEventType}
+ * {@link HyperlinkEvent#getSource() getSource()} returns the specified {@code source}
+ * {@link HyperlinkEvent#getURL() getUrl()} returns the href-attribute's value of the event's source
+ * element
+ * {@link HyperlinkEvent#getDescription() getDescription()} returns the text content of the event's
+ * source element
+ * {@link HyperlinkEvent#getInputEvent() getInputEvent()} returns null
+ * {@link HyperlinkEvent#getSourceElement() getSourceElement()} returns null
+ *
+ * @throws IllegalArgumentException
+ * if the specified event can not be converted to a hyperlink event; this is the case if
+ * {@link #canConvertToHyperlinkEvent(Event)} returns false
+ */
+ @SuppressWarnings("static-method")
+ public HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object source) throws IllegalArgumentException {
+ SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source);
+ return converter.convert();
+ }
+
+}
diff --git a/src/main/java/org/codefx/libfx/dom/DomEventType.java b/src/main/java/org/codefx/libfx/dom/DomEventType.java
new file mode 100644
index 0000000..66151e1
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/dom/DomEventType.java
@@ -0,0 +1,128 @@
+package org.codefx.libfx.dom;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import javax.swing.event.HyperlinkEvent.EventType;
+
+/**
+ * The names of those types of DOM events for which an equivalent hyperlink {@link EventType EventType}s exists.
+ *
+ *
+ * @see DOM Level 3 Events Specification - Event
+ * Type List
+ */
+public enum DomEventType {
+
+ // #region INSTANCES
+
+ /**
+ * A mouse click.
+ *
+ * This event can be canceled.
+ *
+ * @see DOM Level 3 Events Specification - CLICK
+ * Event
+ */
+ CLICK("click"),
+
+ /**
+ * The mouse entered an element's boundaries. Is not dispatched when the mouse moves inside the element
+ * between its descendant elements.
+ *
+ * This event can not be canceled, i.e. canceling it has no effect.
+ *
+ * @see DOM Level 3 Events Specification -
+ * MOUSEENTER Event
+ */
+ MOUSE_ENTER("mouseenter"),
+
+ /**
+ * The mouse left an element's boundaries. Is not dispatched when the mouse moves inside the element between
+ * its descendant elements.
+ *
+ * This event can not be canceled, i.e. canceling it has no effect.
+ *
+ * @see DOM Level 3 Events Specification -
+ * MOUSELEAVE Event
+ */
+ MOUSE_LEAVE("mouseleave");
+
+ // #end INSTANCES
+
+ // #region DEFINITION
+
+ /**
+ * The event's name.
+ */
+ private final String domName;
+
+ /**
+ * Creates a new DOM event type with the specified name.
+ *
+ * @param domName
+ * the name of the event as per DOM
+ * Level 3 Events Specification
+ */
+ private DomEventType(String domName) {
+ this.domName = domName;
+ }
+
+ /**
+ * @return the name of the event as per DOM
+ * Level 3 Events Specification
+ */
+ public String getDomName() {
+ return domName;
+ }
+
+ // #end DEFINITION
+
+ // #region HELPER
+
+ /**
+ * Returns the DOM event type for the specified event name.
+ *
+ * @param domEventName
+ * the name of the DOM event as per W3C specification
+ * @return a {@link DomEventType} if it could be determined; otherwise {@link Optional#empty()}
+ * @see DOM Level 3 Events Specification - Event
+ * Type List
+ */
+ public static Optional byName(String domEventName) {
+ Objects.requireNonNull(domEventName, "The argument 'domEventName' must not be null.");
+
+ for (DomEventType type : DomEventType.values())
+ if (type.getDomName().equals(domEventName))
+ return Optional.of(type);
+
+ return Optional.empty();
+ }
+
+ /**
+ * Returns the representation of this DOM event as an {@link EventType HyperlinkEventType} if that is possible.
+ * Otherwise returns an empty Optional.
+ *
+ * @return
+ * {@link #CLICK} → {@link EventType#ACTIVATED ACTIVATED}
+ * {@link #MOUSE_ENTER} → {@link EventType#ENTERED ENTERED}
+ * {@link #MOUSE_LEAVE} → {@link EventType#EXITED EXITED}
+ * Otherwise → {@link Optional#empty() empty}
+ *
+ */
+ public Optional toHyperlinkEventType() {
+ switch (this) {
+ case CLICK:
+ return Optional.of(EventType.ACTIVATED);
+ case MOUSE_ENTER:
+ return Optional.of(EventType.ENTERED);
+ case MOUSE_LEAVE:
+ return Optional.of(EventType.EXITED);
+ default:
+ return Optional.empty();
+ }
+ }
+
+ // #end HELPER
+
+}
diff --git a/src/main/java/org/codefx/libfx/dom/SingleDomEventConverter.java b/src/main/java/org/codefx/libfx/dom/SingleDomEventConverter.java
new file mode 100644
index 0000000..0d3d35c
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/dom/SingleDomEventConverter.java
@@ -0,0 +1,213 @@
+package org.codefx.libfx.dom;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Objects;
+import java.util.Optional;
+
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkEvent.EventType;
+
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.events.Event;
+
+/**
+ * Creates a {@link HyperlinkEvent} from the DOM-{@link Event} specified during construction.
+ *
+ * In does the actual work for the {@link DomEventConverter} but is a "one-shot" version in the sense that it can only
+ * convert the event specified during construction.
+ */
+class SingleDomEventConverter {
+
+ /**
+ * The DOM-{@link Event} from which the {@link HyperlinkEvent} will be created.
+ */
+ private final Event domEvent;
+
+ /**
+ * The source of the {@link #domEvent}.
+ */
+ private final Object source;
+
+ /**
+ * Creates a new converter for the specified arguments.
+ *
+ * @param domEvent
+ * the DOM-{@link Event} from which the {@link HyperlinkEvent} will be created
+ * @param source
+ * the source of the {@code domEvent}
+ */
+ public SingleDomEventConverter(Event domEvent, Object source) {
+ Objects.requireNonNull(domEvent, "The argument 'domEvent' must not be null.");
+ Objects.requireNonNull(source, "The argument 'source' must not be null.");
+
+ this.domEvent = domEvent;
+ this.source = source;
+ }
+
+ // #region CONVERT
+
+ /**
+ * Indicates whether the DOM event specified during construction can be converted to a {@link HyperlinkEvent}.
+ *
+ * @return true if the event's {@link Event#getType() type} has an equivalent {@link EventType EventType}
+ */
+ public boolean canConvert() {
+ Optional eventType = getEventTypeFrom(domEvent);
+ return eventType.isPresent();
+ }
+
+ /**
+ * Returns the hyperlink event type equivalent of the specified DOM event if it exists.
+ *
+ * @param domEvent
+ * the DOM-{@link Event} whose {@link Event#getType() type} will be determined
+ * @return the equivalent Hyperlink.{@link EventType} if it exists
+ */
+ private static Optional getEventTypeFrom(Event domEvent) {
+ String domEventName = domEvent.getType();
+ Optional eventType = DomEventType
+ .byName(domEventName)
+ .flatMap(domEventType -> domEventType.toHyperlinkEventType());
+ return eventType;
+ }
+
+ /**
+ * Converts the event specified during construction to a hyperlink event.
+ *
+ * @return a {@link HyperlinkEvent}
+ * @throws IllegalArgumentException
+ * if the specified event can not be converted to a hyperlink event; this is the case if
+ * {@link #canConvert()} returns false
+ */
+ public HyperlinkEvent convert() throws IllegalArgumentException {
+ EventType type = getEventTypeForDomEvent();
+ Optional url = getURL();
+ String linkDescription = getTextContent();
+
+ return new HyperlinkEvent(source, type, url.orElse(null), linkDescription);
+ }
+
+ /**
+ * Returns the hyperlink event type equivalent of the specified DOM event.
+ *
+ * @return the equivalent Hyperlink.{@link EventType}
+ * @throws IllegalArgumentException
+ * if the {@link #domEvent}'s type has no equivalent hyperlink event type
+ */
+ private EventType getEventTypeForDomEvent() throws IllegalArgumentException {
+ Optional eventType = getEventTypeFrom(domEvent);
+ if (eventType.isPresent())
+ return eventType.get();
+ else
+ throw new IllegalArgumentException(
+ "The DOM event '" + domEvent + "' of type '" + domEvent.getType()
+ + "' can not be converted to a hyperlink event.");
+ }
+
+ /**
+ * Returns the {@link #domEvent}'s target's text content.
+ *
+ * @return the description
+ */
+ private String getTextContent() {
+ Element targetElement = (Element) domEvent.getTarget();
+ return targetElement.getTextContent();
+ }
+
+ /**
+ * Returns the URL the interacted hyperlink points to.
+ *
+ * @return the {@link URL} if it could be created
+ */
+ private Optional getURL() {
+ Element targetElement = (Element) domEvent.getTarget();
+ Element anchor = getAnchor(targetElement);
+
+ Optional baseURI = Optional.ofNullable(anchor.getBaseURI());
+ String href = anchor.getAttribute("href");
+ return createURL(baseURI, href);
+ }
+
+ /**
+ * Returns the same element if it is an anchor (in the sense of HTML, i.e. has the a-tag). If it is not the closest
+ * parent which is an anchor is returned. If no such parent exists, an {@link IllegalArgumentException} is thrown.
+ *
+ * @param domElement
+ * the {@link Element} on which the search for an anchor element starts
+ * @return an {@link Element} which is an anchor
+ * @throws IllegalArgumentException
+ * if neither the specified element nor one of its parents is an anchor
+ */
+ private static Element getAnchor(Element domElement) throws IllegalArgumentException {
+ Optional anchor = getAnchorAncestor(Optional.of(domElement));
+ return anchor.orElseThrow(() -> new IllegalArgumentException(
+ "Neither the event's target element nor one of its parent nodes is an anchor."));
+ }
+
+ /**
+ * Searches for an a-tag starting on the specified node and recursing to its ancestors.
+ *
+ * @param domNode
+ * the node which is checked for the a-tag
+ * @return an {@link Optional} containing an anchor if one was found; otherwise an empty {@code Optional}
+ */
+ private static Optional getAnchorAncestor(Optional domNode) {
+ // if there is no node, there can be no anchor, so return empty
+ if (!domNode.isPresent())
+ return Optional.empty();
+
+ Node node = domNode.get();
+
+ // only elements can be anchors, so if the node is no element, recurse to its parent
+ boolean nodeIsNoElement = !(node instanceof Element);
+ if (nodeIsNoElement)
+ return getAnchorAncestor(Optional.ofNullable(node.getParentNode()));
+
+ // if the node is an element, it might be an anchor
+ Element element = (Element) node;
+ boolean isAnchor = element.getTagName().equalsIgnoreCase("a");
+ if (isAnchor)
+ return Optional.of(element);
+
+ // if the element is no anchor, recurse to its parent
+ return getAnchorAncestor(Optional.ofNullable(element.getParentNode()));
+ }
+
+ /**
+ * Creates a URL from the specified base URI and href of the link which caused the event.
+ *
+ * @param baseURI
+ * the base URI of the anchor {@link Element} which caused the event
+ * @param href
+ * the href attribute value of the {@link Element} which caused the event
+ * @return a URL if one could be created
+ */
+ private static Optional createURL(Optional baseURI, String href) {
+ // create URL context from the document's base URI
+ URL context = null;
+ try {
+ if (baseURI.isPresent())
+ context = new URL(baseURI.get());
+ } catch (MalformedURLException e) {
+ // if LibFX supports logging, this could be logged:
+ // "Could not create a URL context from the base URI \"" + baseURI + "\".", e
+ }
+
+ // create URL from context and href
+ try {
+ URL url = new URL(context, href);
+ return Optional.of(url);
+ } catch (MalformedURLException e) {
+ // if LibFX supports logging, this could be logged:
+ // "Could not create a URL from href \"" + href + "\" and context \"" + context + "\"."
+ // until then return empty
+ }
+
+ return Optional.empty();
+ }
+
+ // #end CONVERT
+
+}
diff --git a/src/main/java/org/codefx/libfx/dom/StaticDomEventConverter.java b/src/main/java/org/codefx/libfx/dom/StaticDomEventConverter.java
new file mode 100644
index 0000000..629cc29
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/dom/StaticDomEventConverter.java
@@ -0,0 +1,65 @@
+package org.codefx.libfx.dom;
+
+import java.util.Objects;
+
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkEvent.EventType;
+
+import org.w3c.dom.events.Event;
+
+/**
+ * Class which provides {@link DomEventConverter} methods statically.
+ *
+ * This class is thread-safe, i.e. the provided methods can be called from different threads and concurrent executions
+ * do not interfere with each other.
+ */
+public final class StaticDomEventConverter {
+
+ /**
+ * Indicates whether the specified DOM event can be converted to a {@link HyperlinkEvent}.
+ *
+ * @param domEvent
+ * the DOM-{@link Event}
+ * @return true if the event's {@link Event#getType() type} has an equivalent {@link EventType EventType}
+ * @see DomEventConverter#canConvertToHyperlinkEvent(Event)
+ */
+ public static boolean canConvertToHyperlinkEvent(Event domEvent) {
+ Objects.requireNonNull(domEvent, "The argument 'domEvent' must not be null.");
+
+ Object source = "the source does not matter for this call";
+ SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source);
+ return converter.canConvert();
+ }
+
+ /**
+ * Converts the specified DOM event to a hyperlink event.
+ *
+ * @param domEvent
+ * the DOM-{@link Event} from which the {@link HyperlinkEvent} will be created
+ * @param source
+ * the source of the {@code domEvent}
+ * @return a {@link HyperlinkEvent} with the following properties:
+ *
+ * {@link HyperlinkEvent#getEventType() getEventType()} returns the {@link EventType} corresponding to
+ * the domEvent's type as defined by {@link DomEventType}
+ * {@link HyperlinkEvent#getSource() getSource()} returns the specified {@code source}
+ * {@link HyperlinkEvent#getURL() getUrl()} returns the href-attribute's value of the event's source
+ * element
+ * {@link HyperlinkEvent#getDescription() getDescription()} returns the text content of the event's
+ * source element
+ * {@link HyperlinkEvent#getInputEvent() getInputEvent()} returns null
+ * {@link HyperlinkEvent#getSourceElement() getSourceElement()} returns null
+ *
+ * @throws IllegalArgumentException
+ * if the specified event can not be converted to a hyperlink event; this is the case if
+ * {@link #canConvertToHyperlinkEvent(Event)} returns false
+ * @see DomEventConverter#convertToHyperlinkEvent(Event, Object)
+ */
+ public static HyperlinkEvent convertToHyperlinkEvent(Event domEvent, Object source)
+ throws IllegalArgumentException {
+
+ SingleDomEventConverter converter = new SingleDomEventConverter(domEvent, source);
+ return converter.convert();
+ }
+
+}
diff --git a/src/main/java/org/codefx/libfx/dom/package-info.java b/src/main/java/org/codefx/libfx/dom/package-info.java
new file mode 100644
index 0000000..8ca8a50
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/dom/package-info.java
@@ -0,0 +1,11 @@
+/**
+ *
+ * This package provides functionality around DOM, i.e. classes from {@code org.w3c.dom}.
+ *
+ * Event Conversion The class {@link org.codefx.libfx.dom.DomEventConverter DomEventConverter} defines methods
+ * which allow the conversion of {@link org.w3c.dom.events.Event DOM Events} to {@link javax.swing.event.HyperlinkEvent
+ * HyperlinkEvents}. {@link org.codefx.libfx.dom.StaticDomEventConverter StaticDomEventConverter} gives access to the
+ * same methods without the need of instantiation.
+ */
+package org.codefx.libfx.dom;
+
diff --git a/src/main/java/org/codefx/libfx/listener/handle/GenericListenerHandle.java b/src/main/java/org/codefx/libfx/listener/handle/GenericListenerHandle.java
new file mode 100644
index 0000000..eac4d01
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/listener/handle/GenericListenerHandle.java
@@ -0,0 +1,99 @@
+package org.codefx.libfx.listener.handle;
+
+import java.util.Objects;
+import java.util.function.BiConsumer;
+
+/**
+ * A generic implementation of {@link ListenerHandle} which uses functions specified during construction to
+ * {@link #attach()} and {@link #detach()} the listener to the observable instance.
+ *
+ * @param
+ * the type of the observable instance (e.g {@link javafx.beans.value.ObservableValue ObservableValue} or
+ * {@link javafx.collections.ObservableMap ObservableMap}) to which the listener will be added
+ * @param
+ * the type of the listener which will be added to the observable
+ */
+final class GenericListenerHandle implements ListenerHandle {
+
+ // #region FIELDS
+
+ /**
+ * The observable instance to which the {@link #listener} will be added.
+ */
+ private final O observable;
+
+ /**
+ * The listener which will be added to the {@link #observable}.
+ */
+ private final L listener;
+
+ /**
+ * Called on {@link #attach()}.
+ */
+ private final BiConsumer super O, ? super L> add;
+
+ /**
+ * Called on {@link #detach()}.
+ */
+ private final BiConsumer super O, ? super L> remove;
+
+ /**
+ * Indicates whether the {@link #listener} is currently added to the {@link #observable}.
+ */
+ private boolean attached;
+
+ // #end FIELDS
+
+ // #region CONSTRUCITON
+
+ /**
+ * Creates a new listener handle for the specified arguments. The listener is initially detached.
+ *
+ * @param observable
+ * the observable instance to which the {@code listener} will be added
+ * @param listener
+ * the listener which will be added to the {@code observable}
+ * @param add
+ * called when the {@code listener} must be added to the {@code observable}
+ * @param remove
+ * called when the {@code listener} must be removed from the {@code observable}
+ */
+ public GenericListenerHandle(
+ O observable, L listener, BiConsumer super O, ? super L> add, BiConsumer super O, ? super L> remove) {
+
+ Objects.requireNonNull(observable, "The argument 'observable' must not be null.");
+ Objects.requireNonNull(listener, "The argument 'listener' must not be null.");
+ Objects.requireNonNull(add, "The argument 'add' must not be null.");
+ Objects.requireNonNull(remove, "The argument 'remove' must not be null.");
+
+ this.observable = observable;
+ this.listener = listener;
+ this.add = add;
+ this.remove = remove;
+ }
+
+ // #end CONSTRUCITON
+
+ // #region IMPLEMENTATION OF 'ListenerHandle'
+
+ @Override
+ public void attach() {
+ if (attached)
+ return;
+
+ attached = true;
+ add.accept(observable, listener);
+ }
+
+ @Override
+ public void detach() {
+ if (!attached)
+ return;
+
+ attached = false;
+ remove.accept(observable, listener);
+ }
+
+ // #end IMPLEMENTATION OF 'ListenerHandle'
+
+}
diff --git a/src/main/java/org/codefx/libfx/listener/handle/ListenerHandle.java b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandle.java
new file mode 100644
index 0000000..735ab58
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandle.java
@@ -0,0 +1,28 @@
+package org.codefx.libfx.listener.handle;
+
+/**
+ * A listener handle can be used to {@link #attach() attach} and {@link #detach() detach} a listener to/from some
+ * observable instance. Using the handler the calling code must not manage references to both the observed instance and
+ * the listener, which can improve readability.
+ *
+ * A handle is created and returned by methods which connect a listener with an observable instance. This usually means
+ * that the listener is actually added to the observable but it is also possible to simply return a handler and wait for
+ * the call to {@code attach()} before adding the listener. It is up to such methods to specify this behavior.
+ *
+ * Unless otherwise noted it is not safe to share a handle between different threads. The behavior is undefined if
+ * parallel calls are made to {@code attach()} and/or {@code detach()}.
+ */
+public interface ListenerHandle {
+
+ /**
+ * Adds the listener to the observable. Calling this method when the listener is already added is a no-op and will
+ * not result in the listener being called more than once.
+ */
+ void attach();
+
+ /**
+ * Removes the listener from the observable. Calling this method when the listener is not added is a no-op.
+ */
+ void detach();
+
+}
diff --git a/src/main/java/org/codefx/libfx/listener/handle/ListenerHandleBuilder.java b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandleBuilder.java
new file mode 100644
index 0000000..d935457
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandleBuilder.java
@@ -0,0 +1,220 @@
+package org.codefx.libfx.listener.handle;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.BiConsumer;
+
+/**
+ * A builder for a {@link ListenerHandle}. Note that it is abstract enough to be used for all kinds of
+ * observable/listener relation and not just for those occurring in JavaFX.
+ *
+ * The created handle manages whether the listener is currently attached. The functions specified to
+ * {@link #onAttach(BiConsumer)} and {@link #onDetach(BiConsumer)} are only called when necessary. This is the case
+ *
+ * if {@link ListenerHandle#attach() attach} is called when the listener is not currently added to the observable
+ * if {@link ListenerHandle#detach() detach} is called when the listener is currently added to the observable
+ *
+ * This implies that they can be stateless functions which simply add and remove the listener. The functions are called
+ * with the observable and listener specified during construction.
+ *
+ * The {@link ListenerHandle} returned by this builder is not yet attached, i.e. it does not initially call the
+ * functions given to {@code onAttach} or {@code onDetach}.
+ *
+ * Example
+ *
+ * A typical use looks like this:
+ *
+ *
+ * Property<String> textProperty;
+ * ChangeListener<String> textListener;
+ *
+ * ListenerHandle textListenerHandle = ListenerHandleBuilder
+ * .from(textProperty, textListener)
+ * .onAttach((property, listener) -> property.addListener(listener))
+ * .onDetach((property, listener) -> property.removeListener(listener))
+ * .build();
+ *
+ *
+ * @param
+ * the type of the observable instance (e.g {@link javafx.beans.value.ObservableValue ObservableValue} or
+ * {@link javafx.collections.ObservableMap ObservableMap}) to which the listener will be added
+ * @param
+ * the type of the listener which will be added to the observable
+ */
+public final class ListenerHandleBuilder {
+
+ // #region FIELDS
+
+ /**
+ * The observable instance to which the {@link #listener} will be added.
+ */
+ private final O observable;
+
+ /**
+ * The listener which will be added to the {@link #observable}.
+ */
+ private final L listener;
+
+ /**
+ * Called on {@link ListenerHandle#attach()}.
+ */
+ private Optional> add;
+
+ /**
+ * Called on {@link ListenerHandle#detach()}.
+ */
+ private Optional> remove;
+
+ // #end FIELDS
+
+ // #region CONSTRUCTION
+
+ /**
+ * Creates a builder for a generic {@link ListenerHandle}.
+ *
+ * @param observable
+ * the observable instance to which the {@code listener} will be added
+ * @param listener
+ * the listener which will be added to the {@code observable}
+ */
+ private ListenerHandleBuilder(O observable, L listener) {
+ Objects.requireNonNull(observable, "The argument 'observable' must not be null.");
+ Objects.requireNonNull(listener, "The argument 'listener' must not be null.");
+
+ this.observable = observable;
+ this.listener = listener;
+
+ add = Optional.empty();
+ remove = Optional.empty();
+ }
+
+ /**
+ * Creates a builder for a generic {@link ListenerHandle}.
+ *
+ * @param
+ * the type of the observable instance (e.g {@link javafx.beans.value.ObservableValue ObservableValue} or
+ * {@link javafx.collections.ObservableMap ObservableMap}) to which the listener will be added
+ * @param
+ * the type of the listener which will be added to the observable
+ * @param observable
+ * the observable instance to which the {@code listener} will be added
+ * @param listener
+ * the listener which will be added to the {@code observable}
+ * @return a {@link ListenerHandleBuilder} for a {@link ListenerHandle}.
+ */
+ public static ListenerHandleBuilder from(O observable, L listener) {
+ return new ListenerHandleBuilder<>(observable, listener);
+ }
+
+ // #end CONSTRUCTION
+
+ // #region SET AND BUILD
+
+ /**
+ * Sets the function which is executed when the built {@link ListenerHandle} must add the listener because
+ * {@link ListenerHandle#attach() attach} was called.
+ *
+ * Because the built handle manages whether the listener is currently attached, the function is only called when
+ * necessary, i.e. when {@code attach} is called when the listener is currently not added to the observable.
+ *
+ * @param add
+ * the {@link BiConsumer} called on {@code attach}; the arguments for the function are the observable and
+ * listener specified during this builder's construction
+ * @return this builder for fluent calls
+ */
+ public ListenerHandleBuilder onAttach(BiConsumer super O, ? super L> add) {
+ Objects.requireNonNull(add, "The argument 'add' must not be null.");
+
+ this.add = Optional.of(add);
+ return this;
+ }
+
+ /**
+ * Sets the function which is executed when the built {@link ListenerHandle} must remove the listener because
+ * {@link ListenerHandle#attach() detach} was called.
+ *
+ * Because the built handle manages whether the listener is currently attached, the function is only called when
+ * necessary, i.e. when {@code detach} is called when the listener is currently added to the observable.
+ *
+ * @param remove
+ * the {@link BiConsumer} called on {@code detach}; the arguments for the function are the observable and
+ * listener specified during this builder's construction
+ * @return this builder for fluent calls
+ */
+ public ListenerHandleBuilder onDetach(BiConsumer super O, ? super L> remove) {
+ Objects.requireNonNull(remove, "The argument 'remove' must not be null.");
+
+ this.remove = Optional.of(remove);
+ return this;
+ }
+
+ /**
+ * Creates a new listener handle and attaches the listener. This will only succeed if {@link #onAttach(BiConsumer)}
+ * and {@link #onDetach(BiConsumer)} have been called.
+ *
+ * @return a new {@link ListenerHandle}; initially attached
+ * @throws IllegalStateException
+ * if {@link #onAttach(BiConsumer)} or {@link #onDetach(BiConsumer)} have not been called
+ */
+ public ListenerHandle buildAttached() throws IllegalStateException {
+ ListenerHandle handle = buildDetached();
+ handle.attach();
+ return handle;
+ }
+
+ /**
+ * Creates a new, initially detached listener handle. This will only succeed if {@link #onAttach(BiConsumer)} and
+ * {@link #onDetach(BiConsumer)} have been called.
+ *
+ * @return a new {@link ListenerHandle}; initially detached
+ * @throws IllegalStateException
+ * if {@link #onAttach(BiConsumer)} or {@link #onDetach(BiConsumer)} have not been called
+ */
+ public ListenerHandle buildDetached() throws IllegalStateException {
+ verifyAddAndRemovePresent();
+ return new GenericListenerHandle(observable, listener, add.get(), remove.get());
+ }
+
+ /**
+ * Verifies that {@link #add} and {@link #remove} are present.
+ *
+ * @throws IllegalStateException
+ * if {@link #add} or {@link #remove} is empty.
+ */
+ private void verifyAddAndRemovePresent() throws IllegalStateException {
+ boolean onAttachNotCalled = !add.isPresent();
+ boolean onDetachNotCalled = !remove.isPresent();
+ boolean canBuild = !onAttachNotCalled && !onDetachNotCalled;
+
+ if (canBuild)
+ return;
+ else
+ throwExceptionForMissingCall(onAttachNotCalled, onDetachNotCalled);
+ }
+
+ /**
+ * Throws an {@link IllegalStateException} for a missing call.
+ *
+ * @param onAttachNotCalled
+ * indicates whether {@link #onAttach(BiConsumer)} has been called
+ * @param onDetachNotCalled
+ * indicates whether {@link #onDetach(BiConsumer)} has been called
+ * @throws IllegalStateException
+ * if at least one of the specified booleans is true
+ */
+ private static void throwExceptionForMissingCall(boolean onAttachNotCalled, boolean onDetachNotCalled)
+ throws IllegalStateException {
+
+ if (onAttachNotCalled && onDetachNotCalled)
+ throw new IllegalStateException(
+ "A listener handle can not be build until 'onAttach' and 'onDetach' have been called.");
+
+ if (onAttachNotCalled)
+ throw new IllegalStateException("A listener handle can not be build until 'onAttach' has been called.");
+
+ if (onDetachNotCalled)
+ throw new IllegalStateException("A listener handle can not be build until 'onDetach' has been called.");
+ }
+
+ // #end SET AND BUILD
+}
diff --git a/src/main/java/org/codefx/libfx/listener/handle/ListenerHandles.java b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandles.java
new file mode 100644
index 0000000..87c9bea
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/listener/handle/ListenerHandles.java
@@ -0,0 +1,295 @@
+package org.codefx.libfx.listener.handle;
+
+import javafx.beans.InvalidationListener;
+import javafx.beans.Observable;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.ArrayChangeListener;
+import javafx.collections.ListChangeListener;
+import javafx.collections.MapChangeListener;
+import javafx.collections.ObservableArray;
+import javafx.collections.ObservableList;
+import javafx.collections.ObservableMap;
+import javafx.collections.ObservableSet;
+import javafx.collections.SetChangeListener;
+
+/**
+ * Factory class for functionality surrounding {@link ListenerHandle}s.
+ */
+public class ListenerHandles {
+
+ /**
+ * Private constructor so utility class is not instantiated.
+ */
+ private ListenerHandles() {
+ // nothing to do
+ }
+
+ /**
+ * Creates a {@link ListenerHandleBuilder builder} for a generic {@link ListenerHandle}.
+ *
+ * @param
+ * the type of the observable instance (e.g {@link javafx.beans.value.ObservableValue ObservableValue} or
+ * {@link javafx.collections.ObservableMap ObservableMap}) to which the listener will be added
+ * @param
+ * the type of the listener which will be added to the observable
+ * @param observable
+ * the observable instance to which the {@code listener} will be added
+ * @param listener
+ * the listener which will be added to the {@code observable}
+ * @return a {@link ListenerHandleBuilder} for a {@code ListenerHandle}.
+ * @see ListenerHandleBuilder
+ */
+ public static ListenerHandleBuilder createFor(O observable, L listener) {
+ return ListenerHandleBuilder.from(observable, listener);
+ }
+
+ // Observable + InvalidationListener
+
+ /**
+ * Adds the specified listener to the specified observable and returns a handle for the combination.
+ *
+ * @param observable
+ * the {@link Observable} to which the {@code invalidationListener} will be added
+ * @param invalidationListener
+ * the {@link InvalidationListener} which will be added to the {@code observable}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached
+ */
+ public static ListenerHandle createAttached(Observable observable, InvalidationListener invalidationListener) {
+ ListenerHandle handle = createDetached(observable, invalidationListener);
+ handle.attach();
+ return handle;
+ }
+
+ /**
+ * Creates a listener handle for the specified observable and listener. The listener is not yet attached!
+ *
+ * @param observable
+ * the {@link Observable} to which the {@code invalidationListener} will be added
+ * @param invalidationListener
+ * the {@link InvalidationListener} which will be added to the {@code observableValue}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached
+ */
+ public static ListenerHandle createDetached(Observable observable, InvalidationListener invalidationListener) {
+ return ListenerHandleBuilder
+ .from(observable, invalidationListener)
+ .onAttach((obs, listener) -> obs.addListener(listener))
+ .onDetach((obs, listener) -> obs.removeListener(listener))
+ .buildDetached();
+ }
+
+ // ObservableValue + ChangeListener
+
+ /**
+ * Adds the specified listener to the specified observable value and returns a handle for the combination.
+ *
+ * @param
+ * the type of the value wrapped by the observable
+ * @param observableValue
+ * the {@link ObservableValue} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link ChangeListener} which will be added to the {@code observableValue}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached
+ */
+ public static ListenerHandle createAttached(
+ ObservableValue observableValue, ChangeListener super T> changeListener) {
+
+ ListenerHandle handle = createDetached(observableValue, changeListener);
+ handle.attach();
+ return handle;
+ }
+
+ /**
+ * Creates a listener handle for the specified observable value and listener. The listener is not yet attached!
+ *
+ * @param
+ * the type of the value wrapped by the observable
+ * @param observableValue
+ * the {@link ObservableValue} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link ChangeListener} which will be added to the {@code observableValue}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached
+ */
+ public static ListenerHandle createDetached(
+ ObservableValue observableValue, ChangeListener super T> changeListener) {
+
+ return ListenerHandleBuilder
+ .from(observableValue, changeListener)
+ .onAttach((observable, listener) -> observable.addListener(listener))
+ .onDetach((observable, listener) -> observable.removeListener(listener))
+ .buildDetached();
+ }
+
+ // ObservableArray + ArrayChangeListener
+
+ /**
+ * Adds the specified listener to the specified observable array and returns a handle for the combination.
+ *
+ * @param
+ * the type of the array wrapped by the observable
+ * @param observableArray
+ * the {@link ObservableArray} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link ArrayChangeListener} which will be added to the {@code observableArray}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached
+ */
+ public static > ListenerHandle createAttached(
+ ObservableArray observableArray, ArrayChangeListener changeListener) {
+
+ ListenerHandle handle = createDetached(observableArray, changeListener);
+ handle.attach();
+ return handle;
+ }
+
+ /**
+ * Creates a listener handle for the specified observable array and listener. The listener is not yet attached!
+ *
+ * @param
+ * the type of the array wrapped by the observable
+ * @param observableArray
+ * the {@link ObservableArray} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link ArrayChangeListener} which will be added to the {@code observableArray}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached
+ */
+ public static > ListenerHandle createDetached(
+ ObservableArray observableArray, ArrayChangeListener changeListener) {
+
+ return ListenerHandleBuilder
+ .from(observableArray, changeListener)
+ .onAttach((observable, listener) -> observable.addListener(listener))
+ .onDetach((observable, listener) -> observable.removeListener(listener))
+ .buildDetached();
+ }
+
+ // ObservableList + ListChangeListener
+
+ /**
+ * Adds the specified listener to the specified observable list and returns a handle for the combination.
+ *
+ * @param
+ * the list element type
+ * @param observableList
+ * the {@link ObservableList} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link ListChangeListener} which will be added to the {@code observableList}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached
+ */
+ public static ListenerHandle createAttached(
+ ObservableList observableList, ListChangeListener super E> changeListener) {
+
+ ListenerHandle handle = createDetached(observableList, changeListener);
+ handle.attach();
+ return handle;
+ }
+
+ /**
+ * Creates a listener handle for the specified observable list and listener. The listener is not yet attached!
+ *
+ * @param
+ * the list element type
+ * @param observableList
+ * the {@link ObservableList} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link ListChangeListener} which will be added to the {@code observableList}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached
+ */
+ public static ListenerHandle createDetached(
+ ObservableList observableList, ListChangeListener super E> changeListener) {
+
+ return ListenerHandleBuilder
+ .from(observableList, changeListener)
+ .onAttach((observable, listener) -> observable.addListener(listener))
+ .onDetach((observable, listener) -> observable.removeListener(listener))
+ .buildDetached();
+ }
+
+ // ObservableSet + SetChangeListener
+
+ /**
+ * Adds the specified listener to the specified observable set and returns a handle for the combination.
+ *
+ * @param
+ * the set element type
+ * @param observableSet
+ * the {@link ObservableSet} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link SetChangeListener} which will be added to the {@code observableSet}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached
+ */
+ public static ListenerHandle createAttached(
+ ObservableSet observableSet, SetChangeListener super E> changeListener) {
+
+ ListenerHandle handle = createDetached(observableSet, changeListener);
+ handle.attach();
+ return handle;
+ }
+
+ /**
+ * Creates a listener handle for the specified observable set and listener. The listener is not yet attached!
+ *
+ * @param
+ * the set element type
+ * @param observableSet
+ * the {@link ObservableSet} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link SetChangeListener} which will be added to the {@code observableSet}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached
+ */
+ public static ListenerHandle createDetached(
+ ObservableSet observableSet, SetChangeListener super E> changeListener) {
+
+ return ListenerHandleBuilder
+ .from(observableSet, changeListener)
+ .onAttach((observable, listener) -> observable.addListener(listener))
+ .onDetach((observable, listener) -> observable.removeListener(listener))
+ .buildDetached();
+ }
+
+ // ObservableMap + MapChangeListener
+
+ /**
+ * Adds the specified listener to the specified observable map and returns a handle for the combination.
+ *
+ * @param
+ * the map key element type
+ * @param
+ * the map value element type
+ * @param observableMap
+ * the {@link ObservableMap} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link MapChangeListener} which will be added to the {@code observableMap}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially attached
+ */
+ public static ListenerHandle createAttached(
+ ObservableMap observableMap, MapChangeListener super K, ? super V> changeListener) {
+
+ ListenerHandle handle = createDetached(observableMap, changeListener);
+ handle.attach();
+ return handle;
+ }
+
+ /**
+ * Creates a listener handle for the specified observable map and listener. The listener is not yet attached!
+ *
+ * @param
+ * the map key element type
+ * @param
+ * the map value element type
+ * @param observableMap
+ * the {@link ObservableMap} to which the {@code changeListener} will be added
+ * @param changeListener
+ * the {@link MapChangeListener} which will be added to the {@code observableMap}
+ * @return a {@link ListenerHandle} for the specified arguments; the listener is initially detached
+ */
+ public static ListenerHandle createDetached(
+ ObservableMap observableMap, MapChangeListener super K, ? super V> changeListener) {
+
+ return ListenerHandleBuilder
+ .from(observableMap, changeListener)
+ .onAttach((observable, listener) -> observable.addListener(listener))
+ .onDetach((observable, listener) -> observable.removeListener(listener))
+ .buildDetached();
+ }
+
+}
diff --git a/src/main/java/org/codefx/libfx/listener/handle/package-info.java b/src/main/java/org/codefx/libfx/listener/handle/package-info.java
new file mode 100644
index 0000000..fac5b67
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/listener/handle/package-info.java
@@ -0,0 +1,18 @@
+/**
+ * This package provides classes which make it easier to add and remove listeners from observable instances.
+ *
+ * Using the default JavaFX 8 features, it is necessary to store both the observed instance and the listener if the
+ * latter has to be added or removed repeatedly. A {@link org.codefx.libfx.listener.handle.ListenerHandle
+ * ListenerHandle} encapsulates those references and the state whether a listener is currently added or not. It provides
+ * an {@link org.codefx.libfx.listener.handle.ListenerHandle#attach() attach()} and a
+ * {@link org.codefx.libfx.listener.handle.ListenerHandle#detach() detach} method which add or remove the listener.
+ * Redundant calls (i.e. attaching when the listener is already added) are no-ops.
+ *
+ * All features of LibFX which deal with listeners are aware of {@code ListenerHandle}s and respective methods
+ * will return them. For observable classes included in the JDK, the factory
+ * {@link org.codefx.libfx.listener.handle.ListenerHandles ListenerHandles} provides methods to easily create a handle.
+ *
+ * @see org.codefx.libfx.listener.handle.ListenerHandle ListenerHandle
+ * @see org.codefx.libfx.listener.handle.ListenerHandles ListenerHandles
+ */
+package org.codefx.libfx.listener.handle;
\ No newline at end of file
diff --git a/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservable.java b/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservable.java
index a96d512..da3a497 100644
--- a/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservable.java
+++ b/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservable.java
@@ -9,8 +9,8 @@
import javafx.beans.Observable;
import javafx.beans.value.ObservableValue;
-import org.codefx.libfx.nesting.listener.NestedInvalidationListener;
import org.codefx.libfx.nesting.listener.NestedInvalidationListenerBuilder;
+import org.codefx.libfx.nesting.listener.NestedInvalidationListenerHandle;
/**
* A superclass for builders for all kinds of nested functionality. Holds the nesting hierarchy (outer observable and
@@ -170,14 +170,14 @@ private void fillNestingConstructionKit(NestingConstructionKit kit) {
*
* @param listener
* the added {@link InvalidationListener}
- * @return the {@link NestedInvalidationListener} which can be used to check the nesting's state
+ * @return the {@link NestedInvalidationListenerHandle} which can be used to check the nesting's state
*/
- public NestedInvalidationListener addListener(InvalidationListener listener) {
+ public NestedInvalidationListenerHandle addListener(InvalidationListener listener) {
Nesting nesting = buildNesting();
return NestedInvalidationListenerBuilder
.forNesting(nesting)
.withListener(listener)
- .build();
+ .buildAttached();
}
//#end LISTENERS
@@ -217,7 +217,7 @@ public NestingConstructionKit() {
//#end CONSTRUCTOR
- // #region PROPERTY ACCESS
+ // #region ACCESSORS
/**
* @return the outer {@link ObservableValue}
@@ -243,7 +243,7 @@ public List getNestingSteps() {
return nestingSteps;
}
- //#end PROPERTY ACCESS
+ //#end ACCESSORS
}
diff --git a/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservableValue.java b/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservableValue.java
index 5b6f056..b6e12f7 100644
--- a/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservableValue.java
+++ b/src/main/java/org/codefx/libfx/nesting/AbstractNestingBuilderOnObservableValue.java
@@ -4,7 +4,7 @@
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
-import org.codefx.libfx.nesting.listener.NestedChangeListener;
+import org.codefx.libfx.nesting.listener.NestedChangeListenerHandle;
import org.codefx.libfx.nesting.listener.NestedChangeListenerBuilder;
/**
@@ -55,14 +55,14 @@ protected AbstractNestingBuilderOnObservableValue(
*
* @param listener
* the added {@link ChangeListener}
- * @return the {@link NestedChangeListener} which can be used to check the nesting's state
+ * @return the {@link NestedChangeListenerHandle} which can be used to check the nesting's state
*/
- public NestedChangeListener addListener(ChangeListener super T> listener) {
+ public NestedChangeListenerHandle addListener(ChangeListener super T> listener) {
Nesting nesting = buildNesting();
return NestedChangeListenerBuilder
.forNesting(nesting)
.withListener(listener)
- .build();
+ .buildAttached();
}
//#end LISTENERS
diff --git a/src/main/java/org/codefx/libfx/nesting/DeepNesting.java b/src/main/java/org/codefx/libfx/nesting/DeepNesting.java
index 2eea79a..453e93b 100644
--- a/src/main/java/org/codefx/libfx/nesting/DeepNesting.java
+++ b/src/main/java/org/codefx/libfx/nesting/DeepNesting.java
@@ -182,7 +182,7 @@ private void updateNestingFromLevel(int startLevel) {
new NestingUpdater(startLevel).update();
}
- // #region PROPERTY ACCESS
+ // #region ACCESSORS
/**
* {@inheritDoc}
@@ -192,7 +192,7 @@ public ReadOnlyProperty> innerObservableProperty() {
return inner;
}
- //#end PROPERTY ACCESS
+ //#end ACCESSORS
// #region PRIVATE CLASSES
diff --git a/src/main/java/org/codefx/libfx/nesting/NestingObserver.java b/src/main/java/org/codefx/libfx/nesting/NestingObserver.java
index c599e24..d06d53c 100644
--- a/src/main/java/org/codefx/libfx/nesting/NestingObserver.java
+++ b/src/main/java/org/codefx/libfx/nesting/NestingObserver.java
@@ -213,9 +213,9 @@ public NestingObserverBuilder whenInnerObservableChanges(BiConsumer(this);
+ @SuppressWarnings("unused")
+ NestingObserver observer = new NestingObserver(this);
}
}
diff --git a/src/main/java/org/codefx/libfx/nesting/Nestings.java b/src/main/java/org/codefx/libfx/nesting/Nestings.java
index 7637ae7..59e4009 100644
--- a/src/main/java/org/codefx/libfx/nesting/Nestings.java
+++ b/src/main/java/org/codefx/libfx/nesting/Nestings.java
@@ -6,8 +6,8 @@
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
-import org.codefx.libfx.nesting.listener.NestedChangeListener;
-import org.codefx.libfx.nesting.listener.NestedInvalidationListener;
+import org.codefx.libfx.nesting.listener.NestedChangeListenerHandle;
+import org.codefx.libfx.nesting.listener.NestedInvalidationListenerHandle;
import org.codefx.libfx.nesting.property.NestedDoubleProperty;
import org.codefx.libfx.nesting.property.NestedProperty;
@@ -51,8 +51,8 @@
*
* @see Nesting
* @see NestedProperty
- * @see NestedChangeListener
- * @see NestedInvalidationListener
+ * @see NestedChangeListenerHandle
+ * @see NestedInvalidationListenerHandle
*/
public class Nestings {
diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListener.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListener.java
deleted file mode 100644
index 3f0a9e1..0000000
--- a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListener.java
+++ /dev/null
@@ -1,88 +0,0 @@
-package org.codefx.libfx.nesting.listener;
-
-import java.util.Objects;
-
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanProperty;
-import javafx.beans.property.SimpleBooleanProperty;
-import javafx.beans.value.ChangeListener;
-import javafx.beans.value.ObservableValue;
-
-import org.codefx.libfx.nesting.Nested;
-import org.codefx.libfx.nesting.Nesting;
-import org.codefx.libfx.nesting.NestingObserver;
-import org.codefx.libfx.nesting.property.NestedProperty;
-
-/**
- *
- * Contains a {@link ChangeListener} which is connected to a {@link Nesting}. Simply put, the listener is always added
- * to the nesting's inner observable (more precisely, it is added to the {@link ObservableValue} instance contained in
- * the optional value held by the nesting's {@link Nesting#innerObservableProperty() innerObservable} property).
- *
Inner Observable's Value Changes The listener is added to the nesting's inner observable. So when that
- * observable's value changes, the listener is called as usual.
- * Inner Observable Is Replaced When the nesting's inner observable is replaced by another, the listener is
- * removed from the old and added to the new observable. If one of them is missing, the affected removal or add is not
- * performed, which means the listener might not be added to any observable.
- *
- * Note that if the observable is replaced, the listener is not called ! If this is the desired behavior, a
- * listener has to be added to a {@link NestedProperty}.
- *
- * @param
- * the type of the value wrapped by the {@link ObservableValue}
- */
-public class NestedChangeListener implements Nested {
-
- // #region PROPERTIES
-
- /**
- * The property indicating whether the nesting's inner observable is currently present, i.e. not null.
- */
- private final BooleanProperty innerObservablePresent;
-
- //#end PROPERTIES
-
- // #region CONSTUCTION
-
- /**
- * Creates a new {@link NestedChangeListener} which adds the specified listener to the specified nesting's inner
- * observable.
- *
- * @param nesting
- * the {@link Nesting} to which the listener is added
- * @param listener
- * the {@link ChangeListener} which is added to the nesting's {@link Nesting#innerObservableProperty()
- * innerObservable}
- */
- NestedChangeListener(Nesting extends ObservableValue> nesting, ChangeListener super T> listener) {
- Objects.requireNonNull(nesting, "The argument 'nesting' must not be null.");
- Objects.requireNonNull(listener, "The argument 'listener' must not be null.");
-
- this.innerObservablePresent = new SimpleBooleanProperty(this, "innerObservablePresent");
-
- NestingObserver
- .forNesting(nesting)
- .withOldInnerObservable(oldInnerObservable -> oldInnerObservable.removeListener(listener))
- .withNewInnerObservable(newInnerObservable -> newInnerObservable.addListener(listener))
- .whenInnerObservableChanges(
- (Boolean any, Boolean newInnerObservablePresent)
- -> innerObservablePresent.set(newInnerObservablePresent))
- .observe();
- }
-
- //#end CONSTUCTION
-
- // #region IMPLEMENTATION OF 'Nested'
-
- @Override
- public ReadOnlyBooleanProperty innerObservablePresentProperty() {
- return innerObservablePresent;
- }
-
- @Override
- public boolean isInnerObservablePresent() {
- return innerObservablePresent.get();
- }
-
- //#end IMPLEMENTATION OF 'NestedProperty'
-
-}
diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilder.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilder.java
index b090c8f..edabc99 100644
--- a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilder.java
+++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerBuilder.java
@@ -8,7 +8,7 @@
import org.codefx.libfx.nesting.Nesting;
/**
- * A builder for a {@link NestedChangeListener}.
+ * A builder for a {@link NestedChangeListenerHandle}.
*
* @param
* the type of the value wrapped by the {@link ObservableValue}
@@ -79,7 +79,7 @@ public static > NestedChangeListenerBuilder listener) {
Objects.requireNonNull(listener, "The argument 'listener' must not be null.");
@@ -93,7 +93,8 @@ public Buildable withListener(ChangeListener super T> listener) {
// #region PRIVATE CLASSES
/**
- * A subtype of {@link NestedChangeListenerBuilder} which can actually build a listener with {@link #build()}.
+ * A subtype of {@link NestedChangeListenerBuilder} which can actually build a listener with
+ * {@link #buildAttached()}.
*/
public class Buildable extends NestedChangeListenerBuilder {
@@ -113,17 +114,38 @@ private Buildable(NestedChangeListenerBuilder builder) {
}
/**
- * Builds a nested change listener. This method can only be called once as the same {@link ChangeListener}
- * should not be added more than once to the same {@link Nesting}.
+ * Builds and {@link NestedChangeListenerHandle#attach() attaches} a nested change listener and returns the
+ * handle for it.
+ *
+ * This method can only be called once as the same {@link ChangeListener} should not be added more than once to
+ * the same {@link Nesting}.
*
- * @return a new instance of {@link NestedChangeListener}
+ * @return a new instance of {@link NestedChangeListenerHandle}; initially attached
+ * @see #buildDetached()
*/
- public NestedChangeListener build() {
+ public NestedChangeListenerHandle buildAttached() {
+ NestedChangeListenerHandle listenerHandle = buildDetached();
+ listenerHandle.attach();
+ return listenerHandle;
+ }
+
+ /**
+ * Builds a nested change listener and returns the handle for it.
+ *
+ * Note that the listener is not yet {@link NestedChangeListenerHandle#attach() attached}!
+ *
+ * This method can only be called once as the same {@link ChangeListener} should not be added more than once to
+ * the same {@link Nesting}.
+ *
+ * @return a new instance of {@link NestedChangeListenerHandle}; initially detached
+ * @see #buildAttached()
+ */
+ public NestedChangeListenerHandle buildDetached() {
if (built)
- throw new IllegalStateException("This builder can only build one 'NestedChangeListener'.");
+ throw new IllegalStateException("This builder can only build one 'NestedChangeListenerHandle'.");
built = true;
- return new NestedChangeListener(nesting, listener);
+ return new NestedChangeListenerHandle(nesting, listener);
}
}
diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerHandle.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerHandle.java
new file mode 100644
index 0000000..e801e6b
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedChangeListenerHandle.java
@@ -0,0 +1,145 @@
+package org.codefx.libfx.nesting.listener;
+
+import java.util.Objects;
+
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+
+import org.codefx.libfx.nesting.Nesting;
+import org.codefx.libfx.nesting.NestingObserver;
+import org.codefx.libfx.nesting.property.NestedProperty;
+
+/**
+ * Contains a {@link ChangeListener} which is connected to a {@link Nesting}. Simply put, the listener is always added
+ * to the nesting's inner observable (more precisely, it is added to the {@link ObservableValue} instance contained in
+ * the optional value held by the nesting's {@link Nesting#innerObservableProperty() innerObservable} property).
+ * Inner Observable's Value Changes The listener is added to the nesting's inner observable. So when that
+ * observable's value changes, the listener is called as usual. Inner Observable Is Replaced When the nesting's
+ * inner observable is replaced by another, the listener is removed from the old and added to the new observable. If one
+ * of them is missing, the affected removal or add is not performed, which means the listener might not be added to any
+ * observable.
+ *
+ * Note that if the observable is replaced, the listener is not called ! If this is the desired behavior, a
+ * listener has to be added to a {@link NestedProperty}.
+ *
+ * @param
+ * the type of the value wrapped by the {@link ObservableValue}
+ */
+public class NestedChangeListenerHandle implements NestedListenerHandle {
+
+ // #region PROPERTIES
+
+ /**
+ * The {@link Nesting} to whose inner observable the {@link #listener} is attached.
+ */
+ private final Nesting extends ObservableValue> nesting;
+
+ /**
+ * The property indicating whether the nesting's inner observable is currently present, i.e. not null.
+ */
+ private final BooleanProperty innerObservablePresent;
+
+ /**
+ * The {@link ChangeListener} which is added to the {@link #nesting}'s inner observable.
+ */
+ private final ChangeListener super T> listener;
+
+ /**
+ * Indicates whether the {@link #listener} is currently attached to the {@link #nesting}'s inner observable.
+ */
+ private boolean attached;
+
+ //#end PROPERTIES
+
+ // #region CONSTUCTION
+
+ /**
+ * Creates a new {@link NestedChangeListenerHandle} which can {@link #attach() attach} the specified listener to the
+ * specified nesting's inner observable.
+ *
+ * The listener is initially detached.
+ *
+ * @param nesting
+ * the {@link Nesting} to which the listener is added
+ * @param listener
+ * the {@link ChangeListener} which is added to the nesting's {@link Nesting#innerObservableProperty()
+ * innerObservable}
+ */
+ NestedChangeListenerHandle(Nesting extends ObservableValue> nesting, ChangeListener super T> listener) {
+ Objects.requireNonNull(nesting, "The argument 'nesting' must not be null.");
+ Objects.requireNonNull(listener, "The argument 'listener' must not be null.");
+
+ this.nesting = nesting;
+ this.innerObservablePresent = new SimpleBooleanProperty(this, "innerObservablePresent");
+ this.listener = listener;
+
+ NestingObserver
+ .forNesting(nesting)
+ .withOldInnerObservable(this::remove)
+ .withNewInnerObservable(this::addIfAttached)
+ .whenInnerObservableChanges(
+ (any, newInnerObservablePresent) -> innerObservablePresent.set(newInnerObservablePresent))
+ .observe();
+ }
+
+ //#end CONSTUCTION
+
+ // #region ADD & REMOVE
+
+ /**
+ * Adds the {@link #listener} to the specified observable, when indicated by {@link #attached}.
+ *
+ * @param observable
+ * the {@link ObservableValue} to which the listener will be added
+ */
+ private void addIfAttached(ObservableValue observable) {
+ if (attached)
+ observable.addListener(listener);
+ }
+
+ /**
+ * Removes the {@link #listener} from the specified observable.
+ *
+ * @param observable
+ * the {@link ObservableValue} from which the listener will be removed.
+ */
+ private void remove(ObservableValue observable) {
+ observable.removeListener(listener);
+ }
+
+ // #end ADD & REMOVE
+
+ // #region IMPLEMENTATION OF 'NestedListenerHandle'
+
+ @Override
+ public void attach() {
+ if (!attached) {
+ attached = true;
+ nesting.innerObservableProperty().getValue().ifPresent(this::addIfAttached);
+ }
+ }
+
+ @Override
+ public void detach() {
+ if (attached) {
+ attached = false;
+ nesting.innerObservableProperty().getValue().ifPresent(this::remove);
+ }
+ }
+
+ @Override
+ public ReadOnlyBooleanProperty innerObservablePresentProperty() {
+ return innerObservablePresent;
+ }
+
+ @Override
+ public boolean isInnerObservablePresent() {
+ return innerObservablePresent.get();
+ }
+
+ //#end IMPLEMENTATION OF 'NestedListenerHandle'
+
+}
diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListener.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListener.java
deleted file mode 100644
index e10d396..0000000
--- a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListener.java
+++ /dev/null
@@ -1,85 +0,0 @@
-package org.codefx.libfx.nesting.listener;
-
-import java.util.Objects;
-
-import javafx.beans.InvalidationListener;
-import javafx.beans.Observable;
-import javafx.beans.property.BooleanProperty;
-import javafx.beans.property.ReadOnlyBooleanProperty;
-import javafx.beans.property.SimpleBooleanProperty;
-
-import org.codefx.libfx.nesting.Nested;
-import org.codefx.libfx.nesting.Nesting;
-import org.codefx.libfx.nesting.NestingObserver;
-import org.codefx.libfx.nesting.property.NestedProperty;
-
-/**
- *
- * Contains an {@link InvalidationListener} which is connected to a {@link Nesting}. Simply put, the listener is always
- * added to the nesting's inner observable (more precisely, it is added to the {@link Observable} instance contained in
- * the optional value held by the nesting's {@link Nesting#innerObservableProperty() innerObservable} property).
- *
Inner Observable's Value is Invalidated The listener is added to the nesting's inner observable. So when
- * that observable's value is invalidated, the listener is called as usual.
- * Inner Observable Is Replaced When the nesting's inner observable is replaced by another, the listener is
- * removed from the old and added to the new observable. If one of them is missing, the affected removal or add is not
- * performed, which means the listener might not be added to any observable.
- *
- * Note that if the observable is replaced, the listener is not called ! If this is the desired behavior, a
- * listener has to be added to a {@link NestedProperty}.
- */
-public class NestedInvalidationListener implements Nested {
-
- // #region PROPERTIES
-
- /**
- * The property indicating whether the nesting's inner observable is currently present, i.e. not null.
- */
- private final BooleanProperty innerObservablePresent;
-
- //#end PROPERTIES
-
- // #region CONSTUCTION
-
- /**
- * Creates a new {@link NestedInvalidationListener} which adds the specified listener to the specified nesting's
- * inner observable.
- *
- * @param nesting
- * the {@link Nesting} to which the listener is added
- * @param listener
- * the {@link InvalidationListener} which is added to the nesting's
- * {@link Nesting#innerObservableProperty() innerObservable}
- */
- NestedInvalidationListener(Nesting extends Observable> nesting, InvalidationListener listener) {
- Objects.requireNonNull(nesting, "The argument 'nesting' must not be null.");
- Objects.requireNonNull(listener, "The argument 'listener' must not be null.");
-
- this.innerObservablePresent = new SimpleBooleanProperty(this, "innerObservablePresent");
-
- NestingObserver
- .forNesting(nesting)
- .withOldInnerObservable(oldInnerObservable -> oldInnerObservable.removeListener(listener))
- .withNewInnerObservable(newInnerObservable -> newInnerObservable.addListener(listener))
- .whenInnerObservableChanges(
- (Boolean any, Boolean newInnerObservablePresent)
- -> innerObservablePresent.set(newInnerObservablePresent))
- .observe();
- }
-
- //#end CONSTUCTION
-
- // #region IMPLEMENTATION OF 'Nested'
-
- @Override
- public ReadOnlyBooleanProperty innerObservablePresentProperty() {
- return innerObservablePresent;
- }
-
- @Override
- public boolean isInnerObservablePresent() {
- return innerObservablePresent.get();
- }
-
- //#end IMPLEMENTATION OF 'NestedProperty'
-
-}
diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilder.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilder.java
index 8dc426d..9ece5a3 100644
--- a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilder.java
+++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerBuilder.java
@@ -7,7 +7,7 @@
import org.codefx.libfx.nesting.Nesting;
/**
- * A builder for a {@link NestedInvalidationListener}.
+ * A builder for a {@link NestedInvalidationListenerHandle}.
*/
public class NestedInvalidationListenerBuilder {
@@ -71,7 +71,7 @@ public static NestedInvalidationListenerBuilder forNesting(Nesting> nesting) {
*
* @param listener
* the {@link InvalidationListener} which will be added to the nesting's inner observable
- * @return a {@link NestedInvalidationListenerBuilder} which provides a {@link Buildable#build() build}-method
+ * @return a {@link NestedInvalidationListenerBuilder} which provides a {@link Buildable#buildAttached() build}-method
*/
public Buildable withListener(InvalidationListener listener) {
Objects.requireNonNull(listener, "The argument 'listener' must not be null.");
@@ -85,7 +85,7 @@ public Buildable withListener(InvalidationListener listener) {
// #region PRIVATE CLASSES
/**
- * A subtype of {@link NestedInvalidationListenerBuilder} which can actually build a listener with {@link #build()}.
+ * A subtype of {@link NestedInvalidationListenerBuilder} which can actually build a listener with {@link #buildAttached()}.
*/
public class Buildable extends NestedInvalidationListenerBuilder {
@@ -105,17 +105,38 @@ private Buildable(NestedInvalidationListenerBuilder builder) {
}
/**
- * Builds a nested invalidation listener. This method can only be called once as the same
- * {@link InvalidationListener} should not be added more than once to the same {@link Nesting}.
+ * Builds and {@link NestedInvalidationListenerHandle#attach() attaches} a nested invalidation listener and
+ * returns the handle for it.
+ *
+ * This method can only be called once as the same {@link InvalidationListener} should not be added more than
+ * once to the same {@link Nesting}.
*
- * @return a new instance of {@link NestedChangeListener}
+ * @return a new instance of {@link NestedInvalidationListenerHandle}; initially attached
+ * @see #buildDetached()
*/
- public NestedInvalidationListener build() {
+ public NestedInvalidationListenerHandle buildAttached() {
+ NestedInvalidationListenerHandle listenerHandle = buildDetached();
+ listenerHandle.attach();
+ return listenerHandle;
+ }
+
+ /**
+ * Builds a nested invalidation listener and returns the handle for it.
+ *
+ * Note that the listener is not yet {@link NestedInvalidationListenerHandle#attach() attached}!
+ *
+ * This method can only be called once as the same {@link InvalidationListener} should not be added more than
+ * once to the same {@link Nesting}.
+ *
+ * @return a new instance of {@link NestedInvalidationListenerHandle}; initially detached
+ * @see #buildAttached()
+ */
+ public NestedInvalidationListenerHandle buildDetached() {
if (built)
- throw new IllegalStateException("This builder can only build one 'NestedInvalidationListener'.");
+ throw new IllegalStateException("This builder can only build one 'NestedInvalidationListenerHandle'.");
built = true;
- return new NestedInvalidationListener(nesting, listener);
+ return new NestedInvalidationListenerHandle(nesting, listener);
}
}
diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerHandle.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerHandle.java
new file mode 100644
index 0000000..3a803da
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedInvalidationListenerHandle.java
@@ -0,0 +1,140 @@
+package org.codefx.libfx.nesting.listener;
+
+import java.util.Objects;
+
+import javafx.beans.InvalidationListener;
+import javafx.beans.Observable;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+
+import org.codefx.libfx.nesting.Nesting;
+import org.codefx.libfx.nesting.NestingObserver;
+import org.codefx.libfx.nesting.property.NestedProperty;
+
+/**
+ * Contains an {@link InvalidationListener} which is connected to a {@link Nesting}. Simply put, the listener is always
+ * added to the nesting's inner observable (more precisely, it is added to the {@link Observable} instance contained in
+ * the optional value held by the nesting's {@link Nesting#innerObservableProperty() innerObservable} property).
+ * Inner Observable's Value is Invalidated The listener is added to the nesting's inner observable. So when that
+ * observable's value is invalidated, the listener is called as usual. Inner Observable Is Replaced When the
+ * nesting's inner observable is replaced by another, the listener is removed from the old and added to the new
+ * observable. If one of them is missing, the affected removal or add is not performed, which means the listener might
+ * not be added to any observable.
+ *
+ * Note that if the observable is replaced, the listener is not called ! If this is the desired behavior, a
+ * listener has to be added to a {@link NestedProperty}.
+ */
+public class NestedInvalidationListenerHandle implements NestedListenerHandle {
+
+ // #region PROPERTIES
+
+ /**
+ * The {@link Nesting} to whose inner observable the {@link #listener} is attached.
+ */
+ private final Nesting extends Observable> nesting;
+
+ /**
+ * The property indicating whether the nesting's inner observable is currently present, i.e. not null.
+ */
+ private final BooleanProperty innerObservablePresent;
+
+ /**
+ * The {@link InvalidationListener} which is added to the {@link #nesting}'s inner observable.
+ */
+ private final InvalidationListener listener;
+
+ /**
+ * Indicates whether the {@link #listener} is currently attached to the {@link #nesting}'s inner observable.
+ */
+ private boolean attached;
+
+ //#end PROPERTIES
+
+ // #region CONSTUCTION
+
+ /**
+ * Creates a new {@link NestedInvalidationListenerHandle} which adds the specified listener to the specified
+ * nesting's inner observable.
+ *
+ * @param nesting
+ * the {@link Nesting} to which the listener is added
+ * @param listener
+ * the {@link InvalidationListener} which is added to the nesting's
+ * {@link Nesting#innerObservableProperty() innerObservable}
+ */
+ NestedInvalidationListenerHandle(Nesting extends Observable> nesting, InvalidationListener listener) {
+ Objects.requireNonNull(nesting, "The argument 'nesting' must not be null.");
+ Objects.requireNonNull(listener, "The argument 'listener' must not be null.");
+
+ this.nesting = nesting;
+ this.innerObservablePresent = new SimpleBooleanProperty(this, "innerObservablePresent");
+ this.listener = listener;
+
+ NestingObserver
+ .forNesting(nesting)
+ .withOldInnerObservable(this::remove)
+ .withNewInnerObservable(this::addIfAttached)
+ .whenInnerObservableChanges(
+ (any, newInnerObservablePresent) -> innerObservablePresent.set(newInnerObservablePresent))
+ .observe();
+ }
+
+ //#end CONSTUCTION
+
+ // #region ADD & REMOVE
+
+ /**
+ * Adds the {@link #listener} to the specified observable, when indicated by {@link #attached}.
+ *
+ * @param observable
+ * the {@link Observable} to which the listener will be added
+ */
+ private void addIfAttached(Observable observable) {
+ if (attached)
+ observable.addListener(listener);
+ }
+
+ /**
+ * Removes the {@link #listener} from the specified observable.
+ *
+ * @param observable
+ * the {@link Observable} from which the listener will be removed.
+ */
+ private void remove(Observable observable) {
+ observable.removeListener(listener);
+ }
+
+ // #end ADD & REMOVE
+
+ // #region IMPLEMENTATION OF 'NestedListenerHandle'
+
+ @Override
+ public void attach() {
+ if (!attached) {
+ attached = true;
+ nesting.innerObservableProperty().getValue().ifPresent(this::addIfAttached);
+ }
+ }
+
+ @Override
+ public void detach() {
+ if (attached) {
+ attached = false;
+ nesting.innerObservableProperty().getValue().ifPresent(this::remove);
+ }
+ }
+
+ @Override
+ public ReadOnlyBooleanProperty innerObservablePresentProperty() {
+ return innerObservablePresent;
+ }
+
+ @Override
+ public boolean isInnerObservablePresent() {
+ return innerObservablePresent.get();
+ }
+
+ //#end IMPLEMENTATION OF 'NestedListenerHandle'
+
+}
diff --git a/src/main/java/org/codefx/libfx/nesting/listener/NestedListenerHandle.java b/src/main/java/org/codefx/libfx/nesting/listener/NestedListenerHandle.java
new file mode 100644
index 0000000..d92ecb5
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/nesting/listener/NestedListenerHandle.java
@@ -0,0 +1,15 @@
+package org.codefx.libfx.nesting.listener;
+
+import org.codefx.libfx.listener.handle.ListenerHandle;
+import org.codefx.libfx.nesting.Nested;
+
+/**
+ * A {@link ListenerHandle} for a listener added to the inner observable of a {@link org.codefx.libfx.nesting.Nesting
+ * Nesting}.
+ *
+ * @see Nested
+ * @see ListenerHandle
+ */
+public interface NestedListenerHandle extends Nested, ListenerHandle {
+ // no additional methods defined
+}
diff --git a/src/main/java/org/codefx/libfx/nesting/package-info.java b/src/main/java/org/codefx/libfx/nesting/package-info.java
index 2deb9d3..7edbf5f 100644
--- a/src/main/java/org/codefx/libfx/nesting/package-info.java
+++ b/src/main/java/org/codefx/libfx/nesting/package-info.java
@@ -15,15 +15,15 @@
*
Nested Listeners A {@code Nesting} can also be used to add listeners to its inner observable. These
* listeners are moved from one observable to the next as they are replaced.
*
- * See the comments on {@link org.codefx.libfx.nesting.listener.NestedChangeListener NestedChangeListener} and
- * {@link org.codefx.libfx.nesting.listener.NestedInvalidationListener NestedInvalidationListener} for details.
+ * See the comments on {@link org.codefx.libfx.nesting.listener.NestedChangeListenerHandle NestedChangeListener} and
+ * {@link org.codefx.libfx.nesting.listener.NestedInvalidationListenerHandle NestedInvalidationListener} for details.
*
Builders Instances of the classes described above can be build by starting with the methods in
* {@link org.codefx.libfx.nesting.Nestings Nestings}.
*
* @see org.codefx.libfx.nesting.Nesting Nesting
* @see org.codefx.libfx.nesting.property.NestedProperty NestedProperty
- * @see org.codefx.libfx.nesting.listener.NestedChangeListener NestedChangeListener
- * @see org.codefx.libfx.nesting.listener.NestedInvalidationListener NestedInvalidationListener
+ * @see org.codefx.libfx.nesting.listener.NestedChangeListenerHandle NestedChangeListener
+ * @see org.codefx.libfx.nesting.listener.NestedInvalidationListenerHandle NestedInvalidationListener
*/
package org.codefx.libfx.nesting;
diff --git a/src/main/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyBuilder.java b/src/main/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyBuilder.java
index 6dfbdf6..b68407a 100644
--- a/src/main/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyBuilder.java
+++ b/src/main/java/org/codefx/libfx/nesting/property/AbstractNestedPropertyBuilder.java
@@ -63,7 +63,7 @@ protected AbstractNestedPropertyBuilder(Nesting nesting) {
//#end ABSTRACT METHODS
- // #region PROPERTY ACCESS
+ // #region ACCESSORS
/**
* @return the nesting which will be used for all nested properties
@@ -133,6 +133,6 @@ public AbstractNestedPropertyBuilder setName(String name) {
return this;
}
- //#end PROPERTY ACCESS
+ //#end ACCESSORS
}
diff --git a/src/main/java/org/codefx/libfx/serialization/SerializableOptional.java b/src/main/java/org/codefx/libfx/serialization/SerializableOptional.java
new file mode 100644
index 0000000..c93aba5
--- /dev/null
+++ b/src/main/java/org/codefx/libfx/serialization/SerializableOptional.java
@@ -0,0 +1,235 @@
+package org.codefx.libfx.serialization;
+
+import java.io.IOException;
+import java.io.InvalidObjectException;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Convenience class to wrap an {@link Optional} for serialization. Instances of this class are immutable.
+ *
+ * Note that it does not provide any of the methods {@code Optional} has as its only goal is to enable serialization.
+ * But it holds a reference to the {@code Optional} which was used to create it (can be accessed with
+ * {@link #asOptional()}). This {@code Optional} instance is of course reconstructed on deserialization, so it will not
+ * be the same as the one specified for its creation.
+ *
+ * The class can be used as an argument or return type for serialization-based RPC technologies like RMI.
+ *
+ * There are three ways to use this class to serialize instances which have an optional field.
+ *
+ * Transform On Serialization
+ *
+ * The field can be declared as {@code transient Optional optionalField}, which will exclude it from serialization.
+ *
+ * The class then needs to implement custom (de)serialization methods {@code writeObject} and {@code readObject}. They
+ * must transform the {@code optionalField} to a {@code SerializableOptional} when writing the object and after reading
+ * such an instance transform it back to an {@code Optional}.
+ *
+ * Example
+ *
+ *
+ * private void writeObject(ObjectOutputStream out) throws IOException {
+ * out.defaultWriteObject();
+ * out.writeObject(
+ * SerializableOptional.fromOptional(optionalField));
+ * }
+ *
+ * private void readObject(ObjectInputStream in)
+ * throws IOException, ClassNotFoundException {
+ *
+ * in.defaultReadObject();
+ * optionalField =
+ * ((SerializableOptional<T>) in.readObject()).toOptional();
+ * }
+ *
+ *
+ * Transform On Replace
+ *
+ * If the class is serialized using the Serialization Proxy Pattern (see Effective Java, 2nd Edition by Joshua
+ * Bloch, Item 78), the proxy can have an instance of {@link SerializableOptional} to clearly denote the field as being
+ * optional.
+ *
+ * In this case, the proxy needs to transform the {@code Optional} to {@code SerializableOptional} in its constructor
+ * (using {@link SerializableOptional#fromOptional(Optional)}) and the other way in {@code readResolve()} (with
+ * {@link SerializableOptional#asOptional()}).
+ *
+ * Transform On Access
+ *
+ * The field can be declared as {@code SerializableOptional optionalField}. This will include it in the
+ * (de)serialization process so it does not need to be customized.
+ *
+ * But methods interacting with the field need to get an {@code Optional} instead. This can easily be done by writing
+ * the accessor methods such that they transform the field on each access.
+ *
+ * Note that {@link #asOptional()} simply returns the {@code Optional} which with this instance was created so no
+ * constructor needs to be invoked.
+ *
+ * Example
+ *
+ * Note that it is rarely useful to expose an optional field via accessor methods. Hence the following are private and
+ * for use inside the class.
+ *
+ *
+ * private Optional<T> getOptionalField() {
+ * return optionalField.asOptional();
+ * }
+ *
+ * private void setOptionalField(Optional<T> optionalField) {
+ * this.optionalField = SerializableOptional.fromOptional(optionalField);
+ * }
+ *
+ *
+ * @param
+ * the type of the wrapped value
+ */
+public final class SerializableOptional implements Serializable {
+
+ // FIELDS
+
+ @SuppressWarnings("javadoc")
+ private static final long serialVersionUID = -652697447004597911L;
+
+ /**
+ * The wrapped {@link Optional}. Note that this field is transient so it will not be (de)serializd automatically.
+ */
+ private final Optional optional;
+
+ // CONSTRUCTION AND TRANSFORMATION
+
+ /**
+ * Creates a new instance. Private to enforce use of {@link #fromOptional(Optional)}.
+ *
+ * @param optional
+ * the wrapped {@link Optional}
+ */
+ private SerializableOptional(Optional optional) {
+ Objects.requireNonNull(optional, "The argument 'optional' must not be null.");
+ this.optional = optional;
+ }
+
+ /**
+ * Creates a serializable optional from the specified optional.
+ *
+ * @param
+ * the type of the wrapped value
+ * @param optional
+ * the {@link Optional} from which the serializable wrapper will be created
+ * @return a {@link SerializableOptional} which wraps the specified optional
+ */
+ public static SerializableOptional fromOptional(Optional optional) {
+ return new SerializableOptional<>(optional);
+ }
+
+ /**
+ * Creates a serializable optional which wraps an empty optional.
+ *
+ * @param
+ * the type of the non-existent value
+ * @return a {@link SerializableOptional} which wraps an {@link Optional#empty() empty} {@link Optional}
+ * @see Optional#of(Object)
+ */
+ public static SerializableOptional empty() {
+ return new SerializableOptional<>(Optional.empty());
+ }
+
+ /**
+ * Creates a serializable optional for the specified value by wrapping it in an {@link Optional}.
+ *
+ * @param
+ * the type of the wrapped value
+ * @param value
+ * the value which will be contained in the wrapped {@link Optional}; must be non-null
+ * @return a {@link SerializableOptional} which wraps the an optional for the specified value
+ * @throws NullPointerException
+ * if {@code value} is null
+ * @see Optional#of(Object)
+ */
+ public static SerializableOptional of(T value) throws NullPointerException {
+ return new SerializableOptional<>(Optional.of(value));
+ }
+
+ /**
+ * Creates a serializable optional for the specified value by wrapping it in an {@link Optional}.
+ *
+ * @param
+ * the type of the wrapped value
+ * @param value
+ * the value which will be contained in the wrapped {@link Optional}; may be null
+ * @return a {@link SerializableOptional} which wraps the an optional for the specified value
+ * @see Optional#ofNullable(Object)
+ */
+ public static SerializableOptional ofNullable(T value) {
+ return new SerializableOptional<>(Optional.ofNullable(value));
+ }
+
+ /**
+ * Returns the {@code Optional} instance with which this instance was created.
+ *
+ * @return this instance as an {@link Optional}
+ */
+ public Optional asOptional() {
+ return optional;
+ }
+
+ // SERIALIZATION
+
+ /**
+ * Implements the "write part" of the Serialization Proxy Pattern by creating a proxy which will be serialized
+ * instead of this instance.
+ *
+ * @return the {@link SerializationProxy}
+ */
+ private Object writeReplace() {
+ return new SerializationProxy<>(this);
+ }
+
+ /**
+ * Since this class should never be deserialized directly, this method should not be called. If it is, someone
+ * purposely created a serialization of this class to bypass that mechanism, so throw an exception.
+ */
+ @SuppressWarnings({ "static-method", "javadoc", "unused" })
+ private void readObject(ObjectInputStream in) throws IOException {
+ throw new InvalidObjectException("Serialization proxy expected.");
+ }
+
+ /**
+ * The proxy which is serialized instead of an instance of {@link SerializableOptional}.
+ *
+ * @param
+ * the type of the wrapped value
+ */
+ private static class SerializationProxy implements Serializable {
+
+ @SuppressWarnings("javadoc")
+ private static final long serialVersionUID = -1326520485869949065L;
+
+ /**
+ * This value is (de)serialized. It comes from the {@link Optional} wrapped by the {@code SerializableOptional}.
+ */
+ private final T value;
+
+ /**
+ * Creates a new serialization proxy for the specified serializable optional.
+ *
+ * @param serializableOptional
+ * the {@link SerializableOptional} for which this proxy is created
+ */
+ public SerializationProxy(SerializableOptional