You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2020/04/19 21:47:38 UTC

[sis] branch geoapi-4.0 updated: First draft of a `ChoiceBox` for choosing a CRS in a list of more recently used CRS.

This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 1918205  First draft of a `ChoiceBox` for choosing a CRS in a list of more recently used CRS.
1918205 is described below

commit 191820582121738879f6841243ee2e72bb0c2f04
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sun Apr 19 23:43:27 2020 +0200

    First draft of a `ChoiceBox` for choosing a CRS in a list of more recently used CRS.
---
 .../main/java/org/apache/sis/gui/DataViewer.java   |   1 +
 .../java/org/apache/sis/gui/coverage/Controls.java |  12 +-
 .../apache/sis/gui/coverage/CoverageControls.java  |  46 +-
 .../apache/sis/gui/coverage/CoverageExplorer.java  |  36 +-
 .../org/apache/sis/gui/coverage/GridControls.java  |   2 +-
 .../org/apache/sis/gui/referencing/CRSChooser.java |   2 +-
 .../sis/gui/referencing/ObjectStringConverter.java | 110 ++++
 .../gui/referencing/RecentReferenceSystems.java    | 654 +++++++++++++++++++++
 .../org/apache/sis/gui/referencing/WKTPane.java    |   2 +-
 .../apache/sis/internal/gui/ExceptionReporter.java |  10 +
 .../org/apache/sis/internal/gui/GUIUtilities.java  | 197 +++++++
 .../org/apache/sis/internal/gui/RecentChoices.java |  87 +++
 .../java/org/apache/sis/internal/gui/Styles.java   |   3 +-
 .../apache/sis/internal/gui/GUIUtilitiesTest.java  |  45 ++
 .../sis/test/suite/ApplicationTestSuite.java       |  45 ++
 .../java/org/apache/sis/util/CharSequences.java    |   2 +-
 ide-project/NetBeans/nbproject/build-impl.xml      |  22 +-
 ide-project/NetBeans/nbproject/genfiles.properties |   4 +-
 ide-project/NetBeans/nbproject/project.properties  |   1 +
 ide-project/NetBeans/nbproject/project.xml         |   2 +
 20 files changed, 1252 insertions(+), 31 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/DataViewer.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/DataViewer.java
index b3d28f6..0188c41 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/DataViewer.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/DataViewer.java
@@ -229,6 +229,7 @@ public class DataViewer extends Application {
     @Override
     public void stop() throws Exception {
         BackgroundThreads.stop();
+        RecentChoices.saveReferenceSystems();
         super.stop();
     }
 }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/Controls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/Controls.java
index 39e6ee3..00764a8 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/Controls.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/Controls.java
@@ -46,6 +46,11 @@ abstract class Controls {
     static final Insets CAPTION_MARGIN = new Insets(12, 0, 9, 0);
 
     /**
+     * Margin to keep around captions after the first one.
+     */
+    static final Insets NEXT_CAPTION_MARGIN = new Insets(30, 0, 9, 0);
+
+    /**
      * The border to use for grouping some controls together.
      */
     private static final Border GROUP_BORDER = new Border(new BorderStroke(
@@ -110,10 +115,11 @@ abstract class Controls {
     abstract Control controls();
 
     /**
-     * Invoked after {@link CoverageExplorer#setCoverage(ImageRequest)} for updating the table of
-     * sample dimensions when information become available. This method is invoked in JavaFX thread.
+     * Invoked in JavaFX thread after {@link CoverageExplorer#setCoverage(ImageRequest)} completed.
+     * Implementation should update the GUI with new information available, in particular
+     * the coordinate reference system and the list of sample dimensions.
      *
      * @param  data  the new coverage, or {@code null} if none.
      */
-    abstract void updateBandTable(GridCoverage data);
+    abstract void coverageChanged(GridCoverage data);
 }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
index f38ddc7..177b944 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
@@ -26,9 +26,14 @@ import javafx.scene.layout.GridPane;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.VBox;
 import javafx.beans.property.ObjectProperty;
+import javafx.beans.value.ObservableValue;
+import javafx.scene.control.ChoiceBox;
 import javafx.scene.paint.Color;
-import org.apache.sis.gui.map.StatusBar;
+import org.opengis.referencing.ReferenceSystem;
 import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.gui.referencing.RecentReferenceSystems;
+import org.apache.sis.gui.map.StatusBar;
+import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.util.resources.Vocabulary;
 
 
@@ -57,12 +62,20 @@ final class CoverageControls extends Controls {
     private final BorderPane imageAndStatus;
 
     /**
+     * The coordinate reference system selected in the {@link ChoiceBox}.
+     */
+    private final ObjectProperty<ReferenceSystem> referenceSystem;
+
+    /**
      * Creates a new set of coverage controls.
      *
+     * @param  localized   localized GUI resources, provided in argument because often known by the caller.
      * @param  vocabulary  localized set of words, provided in argument because often known by the caller.
      * @param  coverage    property containing the coverage to show.
      */
-    CoverageControls(final Vocabulary vocabulary, final ObjectProperty<GridCoverage> coverage) {
+    CoverageControls(final Resources localized, final Vocabulary vocabulary,
+            final ObjectProperty<GridCoverage> coverage, final RecentReferenceSystems referenceSystems)
+    {
         final Color background = Color.BLACK;
         view = new CoverageCanvas();
         view.setBackground(background);
@@ -72,18 +85,25 @@ final class CoverageControls extends Controls {
         imageAndStatus.setBottom(statusBar.getView());
         /*
          * "Display" section with the following controls:
+         *    - Coordinate reference system
          *    - Background color
          */
         final VBox displayPane;
         {   // Block for making variables locale to this scope.
+            final ChoiceBox<ReferenceSystem> systems = referenceSystems.createChoiceBox(this::onReferenceSystemSelected);
+            systems.setMaxWidth(Double.POSITIVE_INFINITY);
+            referenceSystem = systems.valueProperty();
+            final Label systemLabel = new Label(localized.getString(Resources.Keys.ReferenceSystem));
+            systemLabel.setPadding(CAPTION_MARGIN);
+            systemLabel.setLabelFor(systems);
             final GridPane gp = createControlGrid(
                 label(vocabulary, Vocabulary.Keys.Background, createBackgroundButton(background)),
                 label(vocabulary, Vocabulary.Keys.ValueRange, RangeType.createButton((p,o,n) -> view.setRangeType(n)))
             );
             final Label label = new Label(vocabulary.getLabel(Vocabulary.Keys.Image));
-            label.setPadding(CAPTION_MARGIN);
+            label.setPadding(NEXT_CAPTION_MARGIN);
             label.setLabelFor(gp);
-            displayPane = new VBox(label, gp);
+            displayPane = new VBox(systemLabel, systems, label, gp);
         }
         /*
          * Put all sections together and have the first one expanded by default.
@@ -108,13 +128,25 @@ final class CoverageControls extends Controls {
     }
 
     /**
-     * Invoked after {@link CoverageExplorer#setCoverage(ImageRequest)} for updating the table of
-     * sample dimensions when information become available. This method is invoked in JavaFX thread.
+     * Invoked in JavaFX thread after {@link CoverageExplorer#setCoverage(ImageRequest)} completed.
+     * This method updates the GUI with new information available, in particular
+     * the coordinate reference system and the list of sample dimensions.
      *
      * @param  data  the new coverage, or {@code null} if none.
      */
     @Override
-    final void updateBandTable(final GridCoverage data) {
+    final void coverageChanged(final GridCoverage data) {
+        if (data != null) {
+            referenceSystem.set(data.getCoordinateReferenceSystem());
+        }
+    }
+
+    /**
+     * Invoked when a new coordinate reference system is selected.
+     */
+    private void onReferenceSystemSelected(final ObservableValue<? extends ReferenceSystem> property,
+                                           final ReferenceSystem oldValue, ReferenceSystem newValue)
+    {
     }
 
     /**
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java
index 194eab8..987191a 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java
@@ -27,10 +27,12 @@ import javafx.beans.value.ObservableValue;
 import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.SimpleObjectProperty;
 import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.internal.gui.ToolbarButton;
 import org.apache.sis.internal.gui.NonNullObjectProperty;
 import org.apache.sis.util.resources.Vocabulary;
+import org.apache.sis.gui.referencing.RecentReferenceSystems;
 import org.apache.sis.gui.Widget;
 
 
@@ -135,6 +137,11 @@ public class CoverageExplorer extends Widget {
     private final Controls[] views;
 
     /**
+     * Handles the {@link javafx.scene.control.ChoiceBox} and menu items for selecting a CRS.
+     */
+    private final RecentReferenceSystems referenceSystems;
+
+    /**
      * Creates an initially empty explorer.
      */
     public CoverageExplorer() {
@@ -142,6 +149,9 @@ public class CoverageExplorer extends Widget {
         viewTypeProperty = new NonNullObjectProperty<>(this, "viewType", View.TABLE);
         coverageProperty.addListener(this::onCoverageSpecified);
         viewTypeProperty.addListener(this::onViewTypeSpecified);
+        referenceSystems = new RecentReferenceSystems();
+        referenceSystems.addUserPreferences();
+        referenceSystems.addAlternatives("EPSG:3395");           // WGS 84 / World Mercator
         /*
          * Prepare buttons to add on the toolbar. Those buttons are not managed by this class;
          * they are managed by org.apache.sis.gui.dataset.DataWindow. We only declare here the
@@ -162,7 +172,7 @@ public class CoverageExplorer extends Widget {
             final Controls c;
             switch (type) {
                 case TABLE: c = new GridControls(vocabulary); break;
-                case IMAGE: c = new CoverageControls(vocabulary, coverageProperty); break;
+                case IMAGE: c = new CoverageControls(localized, vocabulary, coverageProperty, referenceSystems); break;
                 default: throw new AssertionError(type);
             }
             SplitPane.setResizableWithParent(c.controls(), Boolean.FALSE);
@@ -257,7 +267,9 @@ public class CoverageExplorer extends Widget {
     }
 
     /**
-     * Invoked when a new coverage has been specified.
+     * Invoked when a new coverage has been set on the {@link #coverageProperty}.
+     * This method notifies the GUI controls about the change then starts loading
+     * data in a background thread.
      *
      * @param  property  the {@link #coverageProperty} (ignored).
      * @param  previous  ignored.
@@ -268,7 +280,7 @@ public class CoverageExplorer extends Widget {
     {
         if (!isCoverageAdjusting) {
             startLoading(null);                                         // Clear data.
-            updateBandTable(coverage);
+            notifyCoverageChange(coverage);
             if (coverage != null) {
                 startLoading(new ImageRequest(coverage, null));         // Start a background thread.
             }
@@ -284,7 +296,7 @@ public class CoverageExplorer extends Widget {
      * @param  coverage  the new coverage, or {@code null} if loading failed.
      */
     final void onCoverageLoaded(final GridCoverage coverage) {
-        updateBandTable(coverage);
+        notifyCoverageChange(coverage);
         isCoverageAdjusting = true;
         try {
             setCoverage(coverage);
@@ -305,14 +317,22 @@ public class CoverageExplorer extends Widget {
     }
 
     /**
-     * Invoked after {@link #setCoverage(ImageRequest)} for updating the table of sample dimensions
-     * with information become available. This method is invoked in JavaFX thread.
+     * Invoked in JavaFX thread after {@link #setCoverage(ImageRequest)} completion for notifying controls
+     * about the coverage change. Controls should update the GUI with new information available,
+     * in particular the coordinate reference system and the list of sample dimensions.
      *
      * @param  data  the new coverage, or {@code null} if none.
      */
-    private void updateBandTable(final GridCoverage data) {
+    private void notifyCoverageChange(final GridCoverage data) {
+        if (data != null) {
+            final GridGeometry gg = data.getGridGeometry();
+//          referenceSystems.areaOfInterest.set(gg.isDefined(GridGeometry.ENVELOPE) ? gg.getEnvelope() : null);
+            if (gg.isDefined(GridGeometry.CRS)) {
+                referenceSystems.setPreferred(true, gg.getCoordinateReferenceSystem());
+            }
+        }
         for (final Controls c : views) {
-            c.updateBandTable(data);
+            c.coverageChanged(data);
         }
     }
 
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java
index a4a477b..a1e8098 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java
@@ -135,7 +135,7 @@ final class GridControls extends Controls {
      * @param  data  the new coverage, or {@code null} if none.
      */
     @Override
-    final void updateBandTable(final GridCoverage data) {
+    final void coverageChanged(final GridCoverage data) {
         final ObservableList<SampleDimension> items = sampleDimensions.getItems();
         if (data != null) {
             items.setAll(data.getSampleDimensions());
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/CRSChooser.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/CRSChooser.java
index e606fc7..e3f98a1 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/CRSChooser.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/CRSChooser.java
@@ -427,7 +427,7 @@ public class CRSChooser extends Dialog<CoordinateReferenceSystem> {
     /**
      * Shows a dialog to select a {@link CoordinateReferenceSystem}.
      *
-     * @param  parent  parent frame of dialog.
+     * @param  parent  parent frame of dialog, or {@code null} for an unowned dialog.
      * @return the selected {@link CoordinateReferenceSystem}, or empty if none.
      */
     public Optional<CoordinateReferenceSystem> showDialog(final Window parent) {
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/ObjectStringConverter.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/ObjectStringConverter.java
new file mode 100644
index 0000000..88f1d71
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/ObjectStringConverter.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.gui.referencing;
+
+import java.util.Locale;
+import javafx.util.StringConverter;
+import org.opengis.referencing.IdentifiedObject;
+import org.apache.sis.referencing.IdentifiedObjects;
+import org.apache.sis.util.resources.Vocabulary;
+
+
+/**
+ * Converts an {@link IdentifiedObject} to {@link String} representation to shown in JavaFX control.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+final class ObjectStringConverter<T extends IdentifiedObject> extends StringConverter<T> {
+    /**
+     * The set of items that user can choose.
+     */
+    private final Iterable<? extends T> items;
+
+    /**
+     * The preferred locale for displaying object name, or {@code null} for the default locale.
+     */
+    private final Locale locale;
+
+    /**
+     * The localized "Other…" string.
+     */
+    private String other;
+
+    /**
+     * Creates a new converter.
+     *
+     * @param  items   the set of items that user can choose.
+     * @param  locale  the preferred locale for displaying object name, or {@code null} for the default locale.
+     */
+    ObjectStringConverter(final Iterable<? extends T> items, final Locale locale) {
+        this.items  = items;
+        this.locale = locale;
+    }
+
+    /**
+     * Returns the display name of the given object.
+     *
+     * @param  object  the object for which to get a string representation.
+     * @return the display name of the given object, or {@code null} if none.
+     */
+    @Override
+    public String toString(final T object) {
+        if (object != RecentReferenceSystems.OTHER) {
+            return IdentifiedObjects.getDisplayName(object, locale);
+        } else {
+            if (other == null) {
+                other = Vocabulary.getResources(locale).getString(Vocabulary.Keys.Others) + '…';
+            }
+            return other;
+        }
+    }
+
+    /**
+     * Returns the object for the given name.
+     *
+     * @param  name  name of desired object (may be {@code null}).
+     * @return the desired object, or {@code null} if not found.
+     */
+    @Override
+    public T fromString(final String name) {
+        if (name != null) {
+            T fallback = null;
+            for (final T item : items) {
+                final String candidate = toString(item);
+                if (name.equals(candidate)) {
+                    return item;
+                }
+                if (fallback == null && name.equalsIgnoreCase(candidate)) {
+                    fallback = item;
+                }
+            }
+            if (fallback != null) {
+                return fallback;
+            }
+            // Check heuristic match only if no exact math was found.
+            for (final T item : items) {
+                if (IdentifiedObjects.isHeuristicMatchForName(item, name)) {
+                    return item;
+                }
+            }
+        }
+        return null;
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/RecentReferenceSystems.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/RecentReferenceSystems.java
new file mode 100644
index 0000000..6bff9f8
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/RecentReferenceSystems.java
@@ -0,0 +1,654 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.gui.referencing;
+
+import java.util.List;
+import java.util.ArrayList;
+import javafx.beans.InvalidationListener;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.beans.value.WritableValue;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.scene.control.ChoiceBox;
+import javafx.scene.control.MenuItem;
+import javafx.concurrent.Task;
+import org.opengis.util.FactoryException;
+import org.opengis.geometry.Envelope;
+import org.opengis.referencing.ReferenceSystem;
+import org.opengis.referencing.IdentifiedObject;
+import org.opengis.referencing.crs.CRSAuthorityFactory;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.opengis.metadata.extent.GeographicBoundingBox;
+import org.apache.sis.metadata.iso.extent.Extents;
+import org.apache.sis.geometry.ImmutableEnvelope;
+import org.apache.sis.referencing.CRS;
+import org.apache.sis.referencing.IdentifiedObjects;
+import org.apache.sis.referencing.factory.GeodeticAuthorityFactory;
+import org.apache.sis.referencing.factory.IdentifiedObjectFinder;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.ComparisonMode;
+import org.apache.sis.util.Utilities;
+import org.apache.sis.util.logging.Logging;
+import org.apache.sis.internal.gui.BackgroundThreads;
+import org.apache.sis.internal.gui.ExceptionReporter;
+import org.apache.sis.internal.gui.GUIUtilities;
+import org.apache.sis.internal.gui.NonNullObjectProperty;
+import org.apache.sis.internal.gui.RecentChoices;
+import org.apache.sis.internal.system.Modules;
+import org.apache.sis.internal.util.Strings;
+
+
+/**
+ * A short list (~10 items) of most recently used {@link ReferenceSystem}s.
+ * The list can be shown in a {@link ChoiceBox} or in a list of {@link MenuItem} controls.
+ * The last choice is an "Other…" item which, when selected, popups the {@link CRSChooser}.
+ *
+ * <p>The choices are listed in following order:</p>
+ * <ul>
+ *   <li>The first choice is the native or preferred reference system of visualized data.
+ *       That choice stay always in the first position.</li>
+ *   <li>The last choice is "Other…" and stay always in the last position.</li>
+ *   <li>All other choices between first and last are ordered with most recently used first.</li>
+ * </ul>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public class RecentReferenceSystems {
+    /**
+     * The authority of the {@link #factory} (for example "EPSG"),
+     * or {@code null} for all authorities known to SIS.
+     */
+    private static final String AUTHORITY = null;
+
+    /**
+     * Number of reference systems to always show before all other reference systems.
+     * They are the native of preferred reference system for the visualized data.
+     */
+    private static final int NUM_CORE_SYSTEMS = 1;
+
+    /**
+     * Number of reference systems to shown in {@link ChoiceBox} or {@link MenuItem}s.
+     * The {@value #NUM_CORE_SYSTEMS} core systems are included but not {@link #OTHER}.
+     */
+    private static final int NUM_SHOWN_SYSTEMS = 9;
+
+    /**
+     * Number of reference systems to keep at the end of the list.
+     */
+    private static final int NUM_OTHER_SYSTEMS = 1;
+
+    /**
+     * A pseudo-reference system for the "Other…" choice. We use a null value because {@link ChoiceBox}
+     * seems to insist for inserting a null value in the items list when we remove the selected item.
+     */
+    static final ReferenceSystem OTHER = null;
+
+    /**
+     * The factory to use for creating a Coordinate Reference System from an authority code.
+     * If {@code null}, then the {@linkplain CRS#getAuthorityFactory(String) default factory}
+     * will be fetched when first needed.
+     */
+    private volatile CRSAuthorityFactory factory;
+
+    /**
+     * The area of interest, or {@code null} if none. This is used for filtering the reference systems added by
+     * {@code addAlternatives(…)} and for providing some guidance to user when {@link CRSChooser} is shown.
+     */
+    public final ObjectProperty<Envelope> areaOfInterest;
+
+    /**
+     * The comparison criterion for considering two reference systems as a duplication.
+     * The default value is {@link ComparisonMode#ALLOW_VARIANT}, i.e. axis orders are ignored.
+     */
+    public final ObjectProperty<ComparisonMode> duplicationCriterion;
+
+    /**
+     * Values of controls created by this {@code RecentReferenceSystems} instance. We retain those properties
+     * because modifying the {@link #referenceSystems} list sometime causes controls to clear their selection
+     * if we removed the selected item from the list. We use {@code controlValues} for saving currently selected
+     * values before to modify the item list, and restore selections after we finished to modify the list.
+     */
+    private final List<ObjectProperty<ReferenceSystem>> controlValues;
+
+    /**
+     * Wrapper for a {@link ReferenceSystem} which has not yet been compared with authoritative definitions.
+     * Those wrappers are created when {@link ReferenceSystem} instances have been specified to {@code setPreferred(…)}
+     * or {@code addAlternatives(…)} methods with {@code replaceByAuthoritativeDefinition} argument set to {@code true}.
+     *
+     * @see #setPreferred(boolean, ReferenceSystem)
+     * @see #addAlternatives(boolean, ReferenceSystem...)
+     */
+    private static final class Unverified {
+        /** The reference system to verify. */
+        final ReferenceSystem system;
+
+        /** Flags the given reference system as unverified. */
+        Unverified(final ReferenceSystem system) {
+            this.system = system;
+        }
+    }
+
+    /**
+     * The reference systems either as {@link ReferenceSystem} instances, {@link Unverified} wrappers or
+     * {@link String} codes. All {@code String} elements should be authority codes that {@link #factory}
+     * can recognize. The first item in this list should be the native or preferred reference system.
+     * The {@link #OTHER} reference system is <em>not</em> included in this list.
+     *
+     * <p>The list content is specified by calls to {@code setPreferred(…)} and {@code addAlternatives(…)} methods,
+     * then is filtered by {@link #filterSystems(ImmutableEnvelope, ComparisonMode)} for resolving authority codes
+     * and removing duplicated elements.</p>
+     *
+     * <p>All accesses to this field and to {@link #isModified} field shall be done in a block synchronized
+     * on {@code systemsOrCodes}.</p>
+     */
+    private final List<Object> systemsOrCodes;
+
+    /**
+     * The {@link #systemsOrCodes} elements with all codes or wrappers replaced by {@link ReferenceSystem}
+     * instances and duplicated values removed. This is the list given to JavaFX controls that we build.
+     * This list includes {@link #OTHER} as its last item.
+     */
+    private ObservableList<ReferenceSystem> referenceSystems;
+
+    /**
+     * {@code true} if the {@link #referenceSystems} list needs to be rebuilt from {@link #systemsOrCodes} content.
+     * This field shall be read and modified in a block synchronized on {@link #systemsOrCodes}.
+     *
+     * @see #modified()
+     */
+    private boolean isModified;
+
+    /**
+     * {@code true} if {@code RecentReferenceSystems} is in the process of modifying {@link #referenceSystems} list.
+     * In such we want to temporarily disable the {@link Listener}. This field is read and updated in JavaFX thread.
+     */
+    private boolean isAdjusting;
+
+    /**
+     * Creates a builder which will use the {@linkplain CRS#getAuthorityFactory(String) default authority factory}.
+     */
+    public RecentReferenceSystems() {
+        systemsOrCodes       = new ArrayList<>();
+        areaOfInterest       = new SimpleObjectProperty<>(this, "areaOfInterest");
+        duplicationCriterion = new NonNullObjectProperty<>(this, "duplicationCriterion", ComparisonMode.ALLOW_VARIANT);
+        controlValues        = new ArrayList<>();
+        final InvalidationListener pl = (e) -> modified();
+        areaOfInterest.addListener(pl);
+        duplicationCriterion.addListener(pl);
+    }
+
+    /**
+     * Creates a builder which will use the specified authority factory.
+     *
+     * @param  factory  the factory to use for building CRS from authority codes.
+     *
+     * @see CRS#getAuthorityFactory(String)
+     */
+    public RecentReferenceSystems(final CRSAuthorityFactory factory) {
+        this();
+        ArgumentChecks.ensureNonNull("factory", factory);
+        this.factory = factory;
+    }
+
+    /**
+     * Sets the native or preferred reference system. This is the system to always show as the first
+     * choice and should typically be the native {@link CoordinateReferenceSystem} of visualized data.
+     * If a previous preferred system existed, the previous system will be moved to alternative choices.
+     *
+     * <p>The {@code replaceByAuthoritativeDefinition} argument specifies whether the given reference system should
+     * be replaced by authoritative definition. If {@code true} then for example a <cite>"WGS 84"</cite> geographic
+     * CRS with (<var>longitude</var>, <var>latitude</var>) axis order may be replaced by "EPSG::4326" definition with
+     * (<var>latitude</var>, <var>longitude</var>) axis order.</p>
+     *
+     * @param  replaceByAuthoritativeDefinition  whether the given system should be replaced by authoritative definition.
+     * @param  system  the native or preferred reference system to show as the first choice.
+     */
+    public void setPreferred(final boolean replaceByAuthoritativeDefinition, final ReferenceSystem system) {
+        ArgumentChecks.ensureNonNull("system", system);
+        synchronized (systemsOrCodes) {
+            systemsOrCodes.add(0, replaceByAuthoritativeDefinition ? new Unverified(system) : system);
+            modified();
+        }
+    }
+
+    /**
+     * Sets the native or preferred reference system as an authority code. This is the system to always show as
+     * the first choice and should typically be the native {@link CoordinateReferenceSystem} of visualized data.
+     * If a previous preferred system existed, the previous system will be moved to alternative choices.
+     *
+     * <p>If the given code is not recognized, then the error will be notified at some later time by a call to
+     * {@link #errorOccurred(FactoryException)} in a background thread and the given code will be silently ignored.
+     * This behavior allows the use of codes that depend on whether an optional dependency is present or not,
+     * in particular the <a href="https://sis.apache.org/epsg.html">EPSG dataset</a>.</p>
+     *
+     * @param  code  authority code of the native of preferred reference system to show as the first choice.
+     */
+    public void setPreferred(final String code) {
+        ArgumentChecks.ensureNonEmpty("code", code);
+        synchronized (systemsOrCodes) {
+            systemsOrCodes.add(0, code);
+            modified();
+        }
+    }
+
+    /**
+     * Adds the given reference systems to the list of alternative choices.
+     * If there is duplicated values in the given list or with previously added systems,
+     * then only the first occurrence of duplicated values is retained.
+     * If an {@linkplain #areaOfInterest area of interest} (AOI) is specified,
+     * then reference systems that do not intersect the AOI will be hidden.
+     *
+     * <p>The {@code replaceByAuthoritativeDefinition} argument specifies whether the given reference systems should
+     * be replaced by authoritative definitions. If {@code true} then for example a <cite>"WGS 84"</cite> geographic
+     * CRS with (<var>longitude</var>, <var>latitude</var>) axis order may be replaced by "EPSG::4326" definition with
+     * (<var>latitude</var>, <var>longitude</var>) axis order.</p>
+     *
+     * @param  replaceByAuthoritativeDefinition  whether the given systems should be replaced by authoritative definitions.
+     * @param  systems  the reference systems to add as alternative choices. Null elements are ignored.
+     */
+    public void addAlternatives(final boolean replaceByAuthoritativeDefinition, final ReferenceSystem... systems) {
+        ArgumentChecks.ensureNonNull("systems", systems);
+        synchronized (systemsOrCodes) {
+            for (final ReferenceSystem system : systems) {
+                if (system != null) {
+                    systemsOrCodes.add(replaceByAuthoritativeDefinition ? new Unverified(system) : system);
+                }
+            }
+            modified();
+        }
+        // Check for duplication will be done in `filterSystems()` method.
+    }
+
+    /**
+     * Adds the coordinate reference system identified by the given authority codes.
+     * If there is duplicated values in the given list or with previously added systems,
+     * then only the first occurrence of duplicated values is retained.
+     * If an {@linkplain #areaOfInterest area of interest} (AOI) is specified,
+     * then reference systems that do not intersect the AOI will be hidden.
+     *
+     * <p>If a code is not recognized, then the error will be notified at some later time by a call to
+     * {@link #errorOccurred(FactoryException)} in a background thread and the code will be silently ignored.
+     * This behavior allows the use of codes that depend on whether an optional dependency is present or not,
+     * in particular the <a href="https://sis.apache.org/epsg.html">EPSG dataset</a>.</p>
+     *
+     * @param  codes  authority codes of the coordinate reference systems to add as alternative choices.
+     *                Null or empty elements are ignored.
+     */
+    public void addAlternatives(final String... codes) {
+        ArgumentChecks.ensureNonNull("codes", codes);
+        synchronized (systemsOrCodes) {
+            for (String code : codes) {
+                code = Strings.trimOrNull(code);
+                if (code != null) {
+                    systemsOrCodes.add(code);
+                }
+            }
+            modified();
+        }
+        // Parsing will be done in `filterSystems()` method.
+    }
+
+    /**
+     * Adds the coordinate reference systems saved in user preferences. The user preferences are determined
+     * from the reference systems observed during current execution or previous execution of JavaFX application.
+     * If an {@linkplain #areaOfInterest area of interest} (AOI) is specified,
+     * then reference systems that do not intersect the AOI will be ignored.
+     */
+    public void addUserPreferences() {
+        addAlternatives(RecentChoices.getReferenceSystems());
+    }
+
+    /**
+     * Filters the {@link #systemsOrCodes} list by making sure that it contains only {@link ReferenceSystem} instances.
+     * Authority codes are resolved if possible or removed if they can not be resolved. Unverified CRSs are compared
+     * with authoritative definitions and replaced when a match is found. Duplications are removed.
+     *
+     * <p>This method can be invoked from any thread.</p>
+     *
+     * @param  domain  the {@link #areaOfInterest} value read from JavaFX thread, or {@code null} if none.
+     * @param  mode    the {@link #duplicationCriterion} value read from JavaFX thread.
+     * @return the filtered reference systems, or {@code null} if already filtered.
+     */
+    private List<ReferenceSystem> filterSystems(final ImmutableEnvelope domain, final ComparisonMode mode) {
+        final List<ReferenceSystem> systems;
+        synchronized (systemsOrCodes) {
+            CRSAuthorityFactory factory = this.factory;         // Hide volatile field by local field.
+            if (!isModified) {
+                return null;                                    // Another thread already did the work.
+            }
+            boolean noFactoryFound = false;
+            boolean searchedFinder = false;
+            IdentifiedObjectFinder finder = null;
+            for (int i=systemsOrCodes.size(); --i >= 0;) try {
+                final Object item = systemsOrCodes.get(i);
+                if (item == OTHER) {
+                    systemsOrCodes.remove(i);
+                } else if (item instanceof String) {
+                    /*
+                     * The current list element is an authority code such as "EPSG::4326".
+                     * Replace that code by the full `CoordinateReferenceSystem` instance.
+                     * Note that authority factories are optional, so it is okay if we can
+                     * not resolve the code. In such case the item will be removed.
+                     */
+                    if (!noFactoryFound) {
+                        if (factory == null) {
+                            factory = CRS.getAuthorityFactory(AUTHORITY);
+                        }
+                        systemsOrCodes.set(i, factory.createCoordinateReferenceSystem((String) item));
+                    } else {
+                        systemsOrCodes.remove(i);
+                    }
+                } else if (item instanceof Unverified) {
+                    /*
+                     * The current list element is a `ReferenceSystem` instance but maybe not
+                     * conform to authoritative definition, for example regarding axis order.
+                     * If we can find an authoritative definition, do the replacement.
+                     * If this operation can not be done, accept the reference system as-is.
+                     */
+                    if (!searchedFinder) {
+                        searchedFinder = true;                              // Set now in case an exception is thrown.
+                        if (factory instanceof GeodeticAuthorityFactory) {
+                            finder = ((GeodeticAuthorityFactory) factory).newIdentifiedObjectFinder();
+                        } else {
+                            finder = IdentifiedObjects.newFinder(AUTHORITY);
+                        }
+                        finder.setIgnoringAxes(true);
+                    }
+                    ReferenceSystem system = ((Unverified) item).system;
+                    if (finder != null) {
+                        final IdentifiedObject replacement = finder.findSingleton(system);
+                        if (replacement instanceof ReferenceSystem) {
+                            system = (ReferenceSystem) replacement;
+                        }
+                    }
+                    systemsOrCodes.set(i, system);
+                }
+            } catch (FactoryException e) {
+                errorOccurred(e);
+                systemsOrCodes.remove(i);
+                noFactoryFound = (factory == null);
+            }
+            /*
+             * Search for duplicated values after we finished filtering. This block is inefficient
+             * (execution time of O(N²)) but it should not be an issue if this list is short (e.g.
+             * 20 elements). We cut the list if we reach the maximal amount of systems to keep.
+             */
+            for (int i=0,j; i < (j=systemsOrCodes.size()); i++) {
+                if (i >= RecentChoices.MAXIMUM_REFERENCE_SYSTEMS) {
+                    systemsOrCodes.subList(i, j).clear();
+                    break;
+                }
+                final Object item = systemsOrCodes.get(i);
+                while (--j > i) {
+                    if (Utilities.deepEquals(item, systemsOrCodes.get(j), mode)) {
+                        systemsOrCodes.remove(j);
+                    }
+                }
+            }
+            /*
+             * Finished to filter the `systemsOrCodes` list: all elements are now guaranteed to be
+             * `ReferenceSystem` instances with no duplicated values. Copy those reference systems
+             * in a separated list as a protection against changes in `systemsOrCodes` list that
+             * could happen after this method returned, and also for retaining only the reference
+             * systems that are valid in the area of interest. We do not remove "invalid" CRS
+             * because they would become valid later if the area of interest changes.
+             */
+            final int n = systemsOrCodes.size();
+            systems = new ArrayList<>(Math.min(NUM_SHOWN_SYSTEMS, n) + NUM_OTHER_SYSTEMS);
+            for (int i=0; i<n; i++) {
+                final ReferenceSystem system = (ReferenceSystem) systemsOrCodes.get(i);
+                if (i >= NUM_CORE_SYSTEMS && domain != null) {
+                    final GeographicBoundingBox bbox = Extents.getGeographicBoundingBox(system.getDomainOfValidity());
+                    if (bbox != null && !domain.intersects(new ImmutableEnvelope(bbox))) {
+                        continue;
+                    }
+                }
+                systems.add(system);
+                if (systems.size() >= NUM_SHOWN_SYSTEMS) break;
+            }
+            systems.add(OTHER);
+            isModified   = false;
+            this.factory = factory;         // Save in volatile field.
+        }
+        return systems;
+    }
+
+    /**
+     * Invoked when {@link #systemsOrCodes} has been modified. If the modification happens after
+     * some controls have been created ({@link ChoiceBox} or {@link MenuItem}s), then this method
+     * updates their list of items. The update may happen at some time after this method returned.
+     */
+    private void modified() {
+        synchronized (systemsOrCodes) {
+            isModified = true;
+            if (referenceSystems != null) {
+                updateItems();
+            }
+        }
+    }
+
+    /**
+     * Updates {@link #referenceSystems} with the reference systems added to {@link #systemsOrCodes} list.
+     * The new items may not be added immediately; instead the CRS will be processed in background thread
+     * and copied to the {@link #referenceSystems} list when ready.
+     *
+     * @return the list of items. May be empty on return and filled later.
+     */
+    @SuppressWarnings("ReturnOfCollectionOrArrayField")
+    private ObservableList<ReferenceSystem> updateItems() {
+        if (referenceSystems == null) {
+            referenceSystems = FXCollections.observableArrayList();
+        }
+        synchronized (systemsOrCodes) {
+            systemsOrCodes.addAll(Math.min(systemsOrCodes.size(), NUM_CORE_SYSTEMS), referenceSystems);
+            // Duplicated values will be filtered by the background task below.
+            isModified = true;
+            final ImmutableEnvelope domain = ImmutableEnvelope.castOrCopy(areaOfInterest.get());
+            final ComparisonMode mode = duplicationCriterion.get();
+            BackgroundThreads.execute(new Task<List<ReferenceSystem>>() {
+                /** Filters the {@link ReferenceSystem}s in a background thread. */
+                @Override protected List<ReferenceSystem> call() {
+                    return filterSystems(domain, mode);
+                }
+
+                /** Should never happen. */
+                @Override protected void failed() {
+                    ExceptionReporter.show(this);
+                }
+
+                /** Sets the {@link ChoiceBox} content to the list computed in background thread. */
+                @Override protected void succeeded() {
+                    setReferenceSystems(getValue(), mode);
+                }
+            });
+        }
+        return referenceSystems;
+    }
+
+    /**
+     * Sets the reference systems to the given content. The given list is often similar to current content,
+     * for example with only a reference system that moved to a different index. This method compares the
+     * given list with current one and tries to fire as few change events as possible.
+     *
+     * @param  systems  the new reference systems, or {@code null} for no changes.
+     * @param  mode     the value of {@link #duplicationCriterion} at the time the
+     *                  {@code systems} list has been computed.
+     */
+    private void setReferenceSystems(final List<ReferenceSystem> systems, final ComparisonMode mode) {
+        if (systems != null) {
+            /*
+             * The call to `copyAsDiff(…)` may cause `ChoiceBox` values to be lost if the corresponding item
+             * in the `referenceSystems` list is temporarily removed (before to be inserted elsewhere).
+             * Save the values before to modify the list.
+             */
+            final ReferenceSystem[] values = controlValues.stream().map(ObjectProperty::get).toArray(ReferenceSystem[]::new);
+            try {
+                isAdjusting = true;
+                GUIUtilities.copyAsDiff(systems, referenceSystems);
+            } finally {
+                isAdjusting = false;
+            }
+            /*
+             * Restore the previous selections. This code also serves another purpose: the previous selection
+             * may not be an item in the list.  If the value was set by a call to `ChoiceBox.setValue(…)` and
+             * is a `GeographicCRS` with (λ,φ) axis order, it may have been replaced in the list by a CRS with
+             * (φ,λ) axis order. We need to replace the previous value by the instance in the list, otherwise
+             * `ChoiceBox` will not show the CRS as selected.
+             */
+            final int n = referenceSystems.size();
+            for (int j=0; j<values.length; j++) {
+                ReferenceSystem system = values[j];
+                for (int i=0; i<n; i++) {
+                    final ReferenceSystem candidate = referenceSystems.get(i);
+                    if (Utilities.deepEquals(candidate, system, mode)) {
+                        system = candidate;
+                        break;
+                    }
+                }
+                controlValues.get(j).set(system);
+            }
+        }
+    }
+
+    /**
+     * Invoked when user selects a reference system. If the choice is "Other…", then {@link CRSChooser} popups
+     * and the selected reference system is added to the list of choices. If the selected CRS is different than
+     * the previous one, then {@link RecentChoices} is notified and the user-specified listener is notified.
+     */
+    private final class Listener implements ChangeListener<ReferenceSystem> {
+        /** The user-specified action to execute when a reference system is selected. */
+        private final ChangeListener<ReferenceSystem> action;
+
+        /** Creates a new listener of reference system selection. */
+        Listener(final ChangeListener<ReferenceSystem> action) {
+            this.action = action;
+        }
+
+        /** Invoked when the user selects a reference system or the "Other…" item. */
+        @SuppressWarnings("unchecked")
+        @Override public void changed(final ObservableValue<? extends ReferenceSystem> property,
+                                      final ReferenceSystem oldValue, ReferenceSystem newValue)
+        {
+            if (isAdjusting) {
+                return;
+            }
+            if (newValue == OTHER) {
+                final CRSChooser chooser = new CRSChooser(factory, areaOfInterest.get());
+                newValue = chooser.showDialog(GUIUtilities.getWindow(property)).orElse(null);
+                if (newValue == null) {
+                    newValue = oldValue;
+                } else {
+                    final ObservableList<ReferenceSystem> items = referenceSystems;
+                    final ComparisonMode mode = duplicationCriterion.get();
+                    final int count = items.size() - NUM_OTHER_SYSTEMS;
+                    boolean found = false;
+                    for (int i=0; i<count; i++) {
+                        if (Utilities.deepEquals(newValue, items.get(i), mode)) {
+                            if (i >= NUM_CORE_SYSTEMS) {
+                                items.set(i, newValue);
+                            }
+                            found = true;
+                            break;
+                        }
+                    }
+                    if (!found) {
+                        if (count >= NUM_SHOWN_SYSTEMS) {
+                            items.remove(count - 1);        // Remove the last item before `OTHER`.
+                        }
+                        items.add(Math.min(count, NUM_CORE_SYSTEMS), newValue);
+                    }
+                }
+                /*
+                 * Following cast is safe because this listener is registered only on ObjectProperty
+                 * instances, and the ObjectProperty class implements WritableValue.
+                 */
+                ((WritableValue<ReferenceSystem>) property).setValue(newValue);
+            }
+            if (oldValue != newValue) {
+                /*
+                 * Notify the user-specified listener first. It will typically starts a background process.
+                 * If an exception occurs in that user code, the list of CRS choices will be left unchanged.
+                 */
+                action.changed(property, oldValue, newValue);
+                RecentChoices.useReferenceSystem(IdentifiedObjects.toString(IdentifiedObjects.getIdentifier(newValue, null)));
+                /*
+                 * Move the selected reference system as the first choice after the core systems.
+                 * We need to remove the old value before to add the new one, otherwise it seems
+                 * to confuse the list.
+                 */
+                final ObservableList<ReferenceSystem> items = referenceSystems;
+                final int count = items.size() - NUM_OTHER_SYSTEMS;
+                for (int i=Math.min(count, NUM_CORE_SYSTEMS + 1); --i >= 0;) {
+                    if (items.get(i) == newValue) {
+                        return;
+                    }
+                }
+                for (int i=count; --i >= NUM_CORE_SYSTEMS;) {
+                    if (items.get(i) == newValue) {
+                        items.remove(i);
+                        break;
+                    }
+                }
+                items.add(Math.max(0, Math.min(count, NUM_CORE_SYSTEMS)), newValue);
+            }
+        }
+    }
+
+    /**
+     * Creates a box offering choices among the reference systems specified to this {@code ShortChoiceList}.
+     * The returned control may be initially empty, in which case its content will be automatically set at
+     * a later time (after a background thread finished to process the {@link CoordinateReferenceSystem}s).
+     *
+     * @param  action  the action to execute when a reference system is selected.
+     * @return a choice box with reference systems specified by {@code setPreferred(…)}
+     *         and {@code addAlternatives(…)} methods.
+     */
+    public ChoiceBox<ReferenceSystem> createChoiceBox(final ChangeListener<ReferenceSystem> action) {
+        ArgumentChecks.ensureNonNull("action", action);
+        final ChoiceBox<ReferenceSystem> choices = new ChoiceBox<>(updateItems());
+        choices.setConverter(new ObjectStringConverter<>(choices.getItems(), null));
+        choices.valueProperty().addListener(new Listener(action));
+        controlValues.add(choices.valueProperty());
+        return choices;
+    }
+
+    public MenuItem[] createMenuItems() {
+        return null;
+    }
+
+    /**
+     * Invoked when an error occurred while filtering a {@link ReferenceSystem} instance.
+     * The error may be a failure to convert an EPSG code to a {@link CoordinateReferenceSystem} instance,
+     * or an error during a CRS verification. Some errors may be normal, for example because EPSG dataset
+     * is not expected to be present in every runtime environments. The consequence of this error is "only"
+     * that the CRS will not be listed among the reference systems that the user can choose.
+     *
+     * <p>The default implementation log the error at {@link java.util.logging.Level#FINE}.
+     * No other processing is done; user is not notified unless (s)he paid attention to loggings.</p>
+     *
+     * @param  e  the error that occurred.
+     */
+    protected void errorOccurred(final FactoryException e) {
+        Logging.recoverableException(Logging.getLogger(Modules.APPLICATION), RecentReferenceSystems.class, "updateItems", e);
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/WKTPane.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/WKTPane.java
index c09ebac..384f136 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/WKTPane.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/referencing/WKTPane.java
@@ -179,7 +179,7 @@ final class WKTPane extends StringConverter<Convention> implements ChangeListene
     }
 
     /**
-     * Sets the content to the given coordianate reference system.
+     * Sets the content to the given coordinate reference system.
      */
     private void setContent(final CoordinateReferenceSystem newCRS) {
         text.setEditable(false);     // TODO: make editable if we allow WKT parsing in a future version.
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ExceptionReporter.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ExceptionReporter.java
index 8e0c617..d8902ad 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ExceptionReporter.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ExceptionReporter.java
@@ -18,6 +18,7 @@ package org.apache.sis.internal.gui;
 
 import java.io.PrintWriter;
 import java.io.StringWriter;
+import javafx.concurrent.Task;
 import javafx.concurrent.Worker;
 import javafx.concurrent.WorkerStateEvent;
 import javafx.event.ActionEvent;
@@ -212,6 +213,15 @@ public final class ExceptionReporter {
     }
 
     /**
+     * Constructs and shows the exception reporter for the given task.
+     *
+     * @param  task  the task that failed.
+     */
+    public static void show(final Task<?> task) {
+        show(task.getTitle(), null, task.getException());
+    }
+
+    /**
      * Invoked when the user selected the "Copy" action in contextual menu.
      *
      * @param event ignored.
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/GUIUtilities.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/GUIUtilities.java
new file mode 100644
index 0000000..952c784
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/GUIUtilities.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.internal.gui;
+
+import java.util.List;
+import java.util.ArrayList;
+import java.util.Collections;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.ObservableList;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.stage.Window;
+import org.apache.sis.util.Static;
+
+
+/**
+ * Miscellaneous utility methods.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public final class GUIUtilities extends Static {
+    /**
+     * Do not allow instantiation of this class.
+     */
+    private GUIUtilities() {
+    }
+
+    /**
+     * Returns the window of the bean associated to the given property.
+     *
+     * @param  property  the property for which to get the window where it appear.
+     * @return the window, or {@code null} if unknown.
+     */
+    public static Window getWindow(final ObservableValue<?> property) {
+        if (property instanceof ObjectProperty<?>) {
+            final Object bean = ((ObjectProperty<?>) property).getBean();
+            if (bean instanceof Node) {
+                final Scene scene = ((Node) bean).getScene();
+                if (scene != null) {
+                    return scene.getWindow();
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Copies all elements from the given source list to the specified target list,
+     * but with the application of insertion and removal operations only.
+     * This method is useful when the two lists should be similar.
+     * The intend is to causes as few change events as possible.
+     *
+     * @param  <E>     type of elements to copy.
+     * @param  source  the list of elements to copy in the target.
+     * @param  target  the list to modify with as few operations as possible.
+     */
+    @SuppressWarnings("empty-statement")
+    public static <E> void copyAsDiff(final List<? extends E> source, final ObservableList<E> target) {
+        if (source.isEmpty()) {
+            target.clear();
+            return;
+        }
+        if (target.isEmpty()) {
+            target.setAll(source);
+            return;
+        }
+        final List<E> lcs = longestCommonSubsequence(source, target);
+        /*
+         * Remove elements before to add new ones, because some listeners
+         * seem to be confused when a list contains duplicated elements
+         * (the removed elements may be inserted elsewhere).
+         */
+        int upper = target.size();
+        for (int i = lcs.size(); --i >= 0;) {
+            final E keep = lcs.get(i);
+            int lower = upper;
+            while (target.get(--lower) != keep);    // A negative index here would be a bug in LCS computation.
+            if (lower + 1 < upper) {
+                target.remove(lower + 1, upper);
+            }
+            upper = lower;
+        }
+        if (upper != 0) {
+            target.remove(0, upper);
+        }
+        assert lcs.equals(target);                  // Because we removed all elements that were not present in LCS.
+        /*
+         * Now insert the new elements. We move forward for reducing the
+         * number of elements that `ObservableList` will have to shift.
+         * (We moved backward in the removal phase for the same reason).
+         */
+        int lower = 0;
+        for (int i=0; i<target.size(); i++) {
+            final E skip = target.get(i);
+            upper = lower;
+            while (source.get(upper) != skip) upper++;  // An index out of bounds would be a bug in LCS computation.
+            if (lower < upper) {
+                target.addAll(i, source.subList(lower, upper));
+                i += upper - lower;
+            }
+            lower = upper + 1;
+        }
+        upper = source.size();
+        if (lower < upper) {
+            target.addAll(source.subList(lower, upper));
+        }
+        assert source.equals(target);
+    }
+
+    /**
+     * Returns the longest subsequence common to both specified sequences.
+     * This is known as <cite>longest common subsequence</cite> (LCS) problem.
+     * The LCS elements are not required to occupy consecutive positions within the original sequences.
+     *
+     * <div class="note"><b>Example:</b>
+     * for the two following lists <var>x</var> and <var>y</var>,
+     * the longest common subsequence if given by <var>lcs</var> below:
+     *
+     * {@preformat text
+     *   x   :  1 2   4 6 7   9
+     *   y   :  1 2 3     7 8
+     *   lcs :  1 2       7
+     * }
+     * </div>
+     *
+     * This algorithm is useful for computing the differences between two sequences.
+     *
+     * @param  <E>  the type of elements in the sequences.
+     * @param  x    the first sequence for which to compute LCS.
+     * @param  y    the second sequence for which to compute LCS.
+     * @return longest common subsequence (LCS) between the two given sequences.
+     *
+     * <a href="https://en.wikipedia.org/wiki/Longest_common_subsequence_problem">Longest common subsequence problem</a>
+     */
+    static <E> List<E> longestCommonSubsequence(final List<? extends E> x, final List<? extends E> y) {
+        /*
+         * This method could be optimized by excluding the common prefix and common suffix before to build the
+         * matrix below. For now we don't do that because the given lists are small. But we should revisit in
+         * the future if this method become used with longer sequences.
+         */
+        int nx = x.size();
+        int ny = y.size();
+        /*
+         * We need a matrix of size (nx x ny) for storing LCS lengths for all (x[i], y[j]) pairs of elements.
+         * The matrix is augmented by one row and one column where all values in the first row and first column
+         * are zero. We could omit that row and that column for saving space, but it would complexify this code.
+         * For now we don't do that, but we may revisit in the future if this code is used for longer sequences.
+         */
+        final int[][] lengths = new int[nx + 1][ny + 1];
+        for (int i=1; i<=nx; i++) {
+            final int im = i - 1;
+            final E xim = x.get(im);
+            for (int j=1; j<=ny; j++) {
+                final int jm = j - 1;
+                lengths[i][j] = (y.get(jm) == xim)
+                              ? Math.incrementExact(lengths[im][jm])
+                              : Math.max(lengths[i][jm], lengths[im][j]);
+            }
+        }
+        /*
+         * The last cell contains the length of longest subsequence common to both lists.
+         * Following loop is the "traceback" procedure: starting from last cell, follows
+         * the direction where the length decrease.
+         */
+        final List<E> lcs = new ArrayList<>(lengths[nx][ny]);
+        while (nx > 0 && ny > 0) {
+            final int lg = lengths[nx][ny];
+            if (lengths[nx-1][ny] >= lg) {
+                nx--;
+            } else if (lengths[nx][--ny] < lg) {
+                final E ex = x.get(--nx);
+                assert ex == y.get(ny);
+                lcs.add(ex);
+            }
+        }
+        Collections.reverse(lcs);
+        return lcs;
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/RecentChoices.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/RecentChoices.java
index eb0be43..70332e2 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/RecentChoices.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/RecentChoices.java
@@ -18,9 +18,12 @@ package org.apache.sis.internal.gui;
 
 import java.io.File;
 import java.util.List;
+import java.util.Arrays;
 import java.util.prefs.Preferences;
 import javafx.scene.control.ComboBox;
 import javafx.collections.ObservableList;
+import org.apache.sis.util.CharSequences;
+import org.apache.sis.util.collection.FrequencySortedSet;
 
 
 /**
@@ -33,6 +36,15 @@ import javafx.collections.ObservableList;
  */
 public final class RecentChoices {
     /**
+     * Maximum number of reference systems to save in the preferences. Note that this is not necessarily
+     * the maximum number of reference systems shown in the GUI (that maximum is lower), because the GUI
+     * will filter out the reference systems that are valid outside the domain of interest.
+     *
+     * @see #useReferenceSystem(String)
+     */
+    public static final int MAXIMUM_REFERENCE_SYSTEMS = 20;
+
+    /**
      * The nodes where to store user information (for example last directory opened).
      * We want node for the {@code "org.apache.sis.gui"} package, which is the public one.
      */
@@ -44,6 +56,17 @@ public final class RecentChoices {
     private static final String OPEN = "Open";
 
     /**
+     * The node where to store authority (usually EPSG) codes of most recently used coordinate reference systems.
+     */
+    private static final String CRS = "ReferenceSystems";
+
+    /**
+     * The coordinate reference systems used in current JVM run, with most frequently used systems first.
+     * The CRS are stored by their authority codes. Access to this set must be synchronized.
+     */
+    private static final FrequencySortedSet<String> CRS_THIS_RUN = new FrequencySortedSet<>(true);
+
+    /**
      * Do not allow instantiation of this class.
      */
     private RecentChoices() {
@@ -70,6 +93,70 @@ public final class RecentChoices {
     }
 
     /**
+     * Returns the authority codes of most recently used reference systems.
+     *
+     * @return authority codes, or an empty array if none.
+     */
+    public static String[] getReferenceSystems() {
+        final String[] codes;
+        synchronized (CRS_THIS_RUN) {
+            final int n = CRS_THIS_RUN.size();
+            if (n != 0) {
+                codes = CRS_THIS_RUN.toArray(new String[n]);
+            } else {
+                final String value = NODE.get(CRS, null);
+                codes = (String[]) CharSequences.split(value, ',');
+                CRS_THIS_RUN.addAll(Arrays.asList(codes));
+            }
+        }
+        return codes;
+    }
+
+    /**
+     * Notifies the preferences that the CRS identified by the given code has been selected.
+     * If the given value is {@code null}, then it is ignored.
+     *
+     * @param  code  code of the CRS selected by user, or {@code null}.
+     */
+    public static void useReferenceSystem(final String code) {
+        if (code != null) {
+            final String[] codes;
+            synchronized (CRS_THIS_RUN) {
+                if (!CRS_THIS_RUN.add(code.trim())) {
+                    return;
+                }
+                codes = CRS_THIS_RUN.toArray(new String[CRS_THIS_RUN.size()]);
+            }
+            saveReferenceSystems(codes);
+        }
+    }
+
+    /**
+     * Saves the authority codes of most recently used reference systems.
+     * This method should be invoked when the application shutdowns.
+     */
+    public static void saveReferenceSystems() {
+        final String[] codes;
+        synchronized (CRS_THIS_RUN) {
+            codes = CRS_THIS_RUN.toArray(new String[CRS_THIS_RUN.size()]);
+        }
+        saveReferenceSystems(codes);
+    }
+
+    /**
+     * Saves the given list of authority codes.
+     * Only the first {@value #MAXIMUM_REFERENCE_SYSTEMS} codes are saved.
+     */
+    private static void saveReferenceSystems(String[] codes) {
+        if (codes.length != 0) {
+            if (codes.length > MAXIMUM_REFERENCE_SYSTEMS) {
+                codes = Arrays.copyOf(codes, MAXIMUM_REFERENCE_SYSTEMS);
+            }
+            NODE.put(CRS, String.join(",", codes));
+        }
+    }
+
+    /**
      * Returns the common parent of a list of files.
      * This is used for selecting which directory to remember for the next open or save dialog box.
      *
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java
index ab4a0f5..735a77d 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Styles.java
@@ -34,6 +34,7 @@ import javafx.beans.value.ChangeListener;
 import javafx.beans.value.ObservableValue;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.internal.system.Modules;
+import org.apache.sis.util.Static;
 
 
 /**
@@ -48,7 +49,7 @@ import org.apache.sis.internal.system.Modules;
  * @since   1.1
  * @module
  */
-public final class Styles {
+public final class Styles extends Static {
     /**
      * Approximate size of vertical scroll bar.
      */
diff --git a/application/sis-javafx/src/test/java/org/apache/sis/internal/gui/GUIUtilitiesTest.java b/application/sis-javafx/src/test/java/org/apache/sis/internal/gui/GUIUtilitiesTest.java
new file mode 100644
index 0000000..11c14d7
--- /dev/null
+++ b/application/sis-javafx/src/test/java/org/apache/sis/internal/gui/GUIUtilitiesTest.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.internal.gui;
+
+import java.util.Arrays;
+import java.util.List;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Tests {@link GUIUtilities}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public final strictfp class GUIUtilitiesTest extends TestCase {
+    /**
+     * Tests {@link GUIUtilities#longestCommonSubsequence(List, List)}.
+     */
+    @Test
+    public void testLongestCommonSubsequence() {
+        final List<Integer> x = Arrays.asList(1, 2, 4, 6, 7,    9);
+        final List<Integer> y = Arrays.asList(1, 2,    3, 7, 8);
+        assertEquals(Arrays.asList(1, 2, 7), GUIUtilities.longestCommonSubsequence(x, y));
+    }
+}
diff --git a/application/sis-javafx/src/test/java/org/apache/sis/test/suite/ApplicationTestSuite.java b/application/sis-javafx/src/test/java/org/apache/sis/test/suite/ApplicationTestSuite.java
new file mode 100644
index 0000000..c960b54
--- /dev/null
+++ b/application/sis-javafx/src/test/java/org/apache/sis/test/suite/ApplicationTestSuite.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.test.suite;
+
+import org.apache.sis.test.TestSuite;
+import org.junit.runners.Suite;
+import org.junit.BeforeClass;
+
+
+/**
+ * All tests from the {@code sis-javafx} module, in rough dependency order.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+@Suite.SuiteClasses({
+    org.apache.sis.internal.gui.GUIUtilitiesTest.class
+})
+public final strictfp class ApplicationTestSuite extends TestSuite {
+    /**
+     * Verifies the list of tests before to run the suite.
+     * See {@link #verifyTestList(Class, Class[])} for more information.
+     */
+    @BeforeClass
+    public static void verifyTestList() {
+        assertNoMissingTest(ApplicationTestSuite.class);
+        verifyTestList(ApplicationTestSuite.class);
+    }
+}
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/CharSequences.java b/core/sis-utility/src/main/java/org/apache/sis/util/CharSequences.java
index 8802403..bab9fe5 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/CharSequences.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/CharSequences.java
@@ -632,7 +632,7 @@ search:     for (; fromIndex <= toIndex; fromIndex++) {
      * @param  text       the text to split, or {@code null}.
      * @param  separator  the delimiting character (typically the coma).
      * @return the array of subsequences computed by splitting the given text around the given
-     *         character, or an empty array if {@code toSplit} was null.
+     *         character, or an empty array if {@code text} was null.
      *
      * @see String#split(String)
      */
diff --git a/ide-project/NetBeans/nbproject/build-impl.xml b/ide-project/NetBeans/nbproject/build-impl.xml
index 3228d8e..c6176df 100644
--- a/ide-project/NetBeans/nbproject/build-impl.xml
+++ b/ide-project/NetBeans/nbproject/build-impl.xml
@@ -180,6 +180,7 @@ is divided into following sections:
         </condition>
         <condition property="have.tests">
             <or>
+                <available file="${test.javafx.dir}"/>
                 <available file="${test.webapp.dir}"/>
                 <available file="${test.console.dir}"/>
                 <available file="${test.portrayal.dir}"/>
@@ -346,6 +347,7 @@ is divided into following sections:
         <fail unless="src.jpn-profile.dir">Must set src.jpn-profile.dir</fail>
         <fail unless="src.gdal.dir">Must set src.gdal.dir</fail>
         <fail unless="src.c.gdal.dir">Must set src.c.gdal.dir</fail>
+        <fail unless="test.javafx.dir">Must set test.javafx.dir</fail>
         <fail unless="test.webapp.dir">Must set test.webapp.dir</fail>
         <fail unless="test.console.dir">Must set test.console.dir</fail>
         <fail unless="test.portrayal.dir">Must set test.portrayal.dir</fail>
@@ -662,6 +664,9 @@ is divided into following sections:
                 <j2seproject3:junit-prototype>
                     <customizePrototype>
                         <batchtest todir="${build.test.results.dir}">
+                            <fileset dir="${test.javafx.dir}" excludes="@{excludes},${excludes}" includes="@{includes}">
+                                <filename name="@{testincludes}"/>
+                            </fileset>
                             <fileset dir="${test.webapp.dir}" excludes="@{excludes},${excludes}" includes="@{includes}">
                                 <filename name="@{testincludes}"/>
                             </fileset>
@@ -739,6 +744,9 @@ is divided into following sections:
                     <isset property="test.method"/>
                 </condition>
                 <union id="test.set">
+                    <fileset dir="${test.javafx.dir}" excludes="@{excludes},**/*.xml,${excludes}" includes="@{includes}">
+                        <filename name="@{testincludes}"/>
+                    </fileset>
                     <fileset dir="${test.webapp.dir}" excludes="@{excludes},**/*.xml,${excludes}" includes="@{includes}">
                         <filename name="@{testincludes}"/>
                     </fileset>
@@ -1869,14 +1877,14 @@ is divided into following sections:
         <!-- You can override this target in the ../build.xml file. -->
     </target>
     <target depends="-init-source-module-properties" if="named.module.internal" name="-init-test-javac-module-properties-with-module">
-        <j2seproject3:modulename property="test.module.name" sourcepath="${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${test.storage.dir}:${test.feature.dir}:${test.referencing.dir}:${test.ref-by-id.dir}:${test.metadata.dir}:${test.utility.dir}:${test.fra-profile.dir}:${test.jpn-profile.dir}:${test.gdal.dir}"/>
-        <condition else="${empty.dir}" property="javac.test.sourcepath" value="${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${test.storage.dir}:${test.feature.dir}:${test.referencing.dir}:${test.ref-by-id.dir}:${test.metadata.dir}:${test.utility.dir}:${test.fra-profile.dir}:${test.jpn-profile.dir}:${test.gdal.dir}">
+        <j2seproject3:modulename property="test.module.name" sourcepath="${test.javafx.dir}:${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${test.storage.dir}:${test.feature.dir}:${test.referencing.dir}:${test.ref-by-id.dir}:${test.metadata.dir}:${test.utility.dir}:${test.fra-profile.dir}:${test.jpn-profile.dir}:${test.gdal.dir}"/>
+        <condition else="${empty.dir}" property="javac.test.sourcepath" value="${test.javafx.dir}:${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${test.storage.dir}:${test.feature.dir}:${test.referencing.dir}:${test.ref-by-id.dir}:${test.metadata.dir}:${test.utility.dir}:${test.fra-profile.dir}:${test.jpn-profile.dir}:${test.gdal.dir}">
             <and>
                 <isset property="test.module.name"/>
                 <length length="0" string="${test.module.name}" when="greater"/>
             </and>
         </condition>
-        <condition else="--patch-module ${module.name}=${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${test.storage.dir}:${test.feature.dir}:${test.referencing.dir}:${test.ref-by-id.dir}:${test.metadata.dir}:${test.utility.dir}:${test.fra-profile.dir}:${test.jpn-profile.dir}:${test.gdal.dir} --add-reads ${module.name}=ALL-UNNAMED" property="javac.test.com [...]
+        <condition else="--patch-module ${module.name}=${test.javafx.dir}:${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${test.storage.dir}:${test.feature.dir}:${test.referencing.dir}:${test.ref-by-id.dir}:${test.metadata.dir}:${test.utility.dir}:${test.fra-profile.dir}:${test.jpn-profile.dir}:${test.gdal.dir} --add-reads ${module.name}=ALL-UNNAMED" prope [...]
             <and>
                 <isset property="test.module.name"/>
                 <length length="0" string="${test.module.name}" when="greater"/>
@@ -1917,15 +1925,16 @@ is divided into following sections:
     </target>
     <target depends="-init-test-javac-module-properties-with-module,-init-test-module-properties-without-module" name="-init-test-module-properties"/>
     <target if="do.depend.true" name="-compile-test-depend">
-        <j2seproject3:depend classpath="${javac.test.classpath}" destdir="${build.test.classes.dir}" srcdir="${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${test.storage.dir}:${test.feature.dir}:${test.referencing.dir}:${test.ref-by-id.dir}:${test.metadata.dir}:${test.utility.dir}:${test.fra-profile.dir}:${test.jpn-profile.dir}:${test.gdal.dir}"/>
+        <j2seproject3:depend classpath="${javac.test.classpath}" destdir="${build.test.classes.dir}" srcdir="${test.javafx.dir}:${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${test.storage.dir}:${test.feature.dir}:${test.referencing.dir}:${test.ref-by-id.dir}:${test.metadata.dir}:${test.utility.dir}:${test.fra-profile.dir}:${test.jpn-profile.dir}:${test.g [...]
     </target>
     <target depends="init,deps-jar,compile,-init-test-module-properties,-pre-pre-compile-test,-pre-compile-test,-compile-test-depend" if="have.tests" name="-do-compile-test">
-        <j2seproject3:javac apgeneratedsrcdir="${build.test.classes.dir}" classpath="${javac.test.classpath}" debug="true" destdir="${build.test.classes.dir}" modulepath="${javac.test.modulepath}" processorpath="${javac.test.processorpath}" sourcepath="${javac.test.sourcepath}" srcdir="${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${test.storage.dir}:${te [...]
+        <j2seproject3:javac apgeneratedsrcdir="${build.test.classes.dir}" classpath="${javac.test.classpath}" debug="true" destdir="${build.test.classes.dir}" modulepath="${javac.test.modulepath}" processorpath="${javac.test.processorpath}" sourcepath="${javac.test.sourcepath}" srcdir="${test.javafx.dir}:${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir}:${tes [...]
             <customize>
                 <compilerarg line="${javac.test.compilerargs}"/>
             </customize>
         </j2seproject3:javac>
         <copy todir="${build.test.classes.dir}">
+            <fileset dir="${test.javafx.dir}" excludes="${build.classes.excludes},${excludes}" includes="${includes}"/>
             <fileset dir="${test.webapp.dir}" excludes="${build.classes.excludes},${excludes}" includes="${includes}"/>
             <fileset dir="${test.console.dir}" excludes="${build.classes.excludes},${excludes}" includes="${includes}"/>
             <fileset dir="${test.portrayal.dir}" excludes="${build.classes.excludes},${excludes}" includes="${includes}"/>
@@ -1958,12 +1967,13 @@ is divided into following sections:
     <target depends="init,deps-jar,compile,-init-test-module-properties,-pre-pre-compile-test,-pre-compile-test-single" if="have.tests" name="-do-compile-test-single">
         <fail unless="javac.includes">Must select some files in the IDE or set javac.includes</fail>
         <j2seproject3:force-recompile destdir="${build.test.classes.dir}"/>
-        <j2seproject3:javac apgeneratedsrcdir="${build.test.classes.dir}" classpath="${javac.test.classpath}" debug="true" destdir="${build.test.classes.dir}" excludes="" includes="${javac.includes}, module-info.java" modulepath="${javac.test.modulepath}" processorpath="${javac.test.processorpath}" sourcepath="${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:${test.xmlstore.dir} [...]
+        <j2seproject3:javac apgeneratedsrcdir="${build.test.classes.dir}" classpath="${javac.test.classpath}" debug="true" destdir="${build.test.classes.dir}" excludes="" includes="${javac.includes}, module-info.java" modulepath="${javac.test.modulepath}" processorpath="${javac.test.processorpath}" sourcepath="${test.javafx.dir}:${test.webapp.dir}:${test.console.dir}:${test.portrayal.dir}:${test.earth-obs.dir}:${test.geotiff.dir}:${test.netcdf.dir}:${test.shapefile.dir}:${test.sql.dir}:$ [...]
             <customize>
                 <compilerarg line="${javac.test.compilerargs}"/>
             </customize>
         </j2seproject3:javac>
         <copy todir="${build.test.classes.dir}">
+            <fileset dir="${test.javafx.dir}" excludes="${build.classes.excludes},${excludes}" includes="${includes}"/>
             <fileset dir="${test.webapp.dir}" excludes="${build.classes.excludes},${excludes}" includes="${includes}"/>
             <fileset dir="${test.console.dir}" excludes="${build.classes.excludes},${excludes}" includes="${includes}"/>
             <fileset dir="${test.portrayal.dir}" excludes="${build.classes.excludes},${excludes}" includes="${includes}"/>
diff --git a/ide-project/NetBeans/nbproject/genfiles.properties b/ide-project/NetBeans/nbproject/genfiles.properties
index 5330355..df802b9 100644
--- a/ide-project/NetBeans/nbproject/genfiles.properties
+++ b/ide-project/NetBeans/nbproject/genfiles.properties
@@ -3,6 +3,6 @@
 build.xml.data.CRC32=58e6b21c
 build.xml.script.CRC32=462eaba0
 build.xml.stylesheet.CRC32=28e38971@1.53.1.46
-nbproject/build-impl.xml.data.CRC32=84cf0137
-nbproject/build-impl.xml.script.CRC32=27d6eb56
+nbproject/build-impl.xml.data.CRC32=59eb8bb4
+nbproject/build-impl.xml.script.CRC32=b6a6bc73
 nbproject/build-impl.xml.stylesheet.CRC32=f89f7d21@1.94.0.48
diff --git a/ide-project/NetBeans/nbproject/project.properties b/ide-project/NetBeans/nbproject/project.properties
index d06b926..82c5377 100644
--- a/ide-project/NetBeans/nbproject/project.properties
+++ b/ide-project/NetBeans/nbproject/project.properties
@@ -54,6 +54,7 @@ run.jvmargs          = -enableassertions ${javafx.options} \
 project.root         = ../..
 src.local-src.dir    = ../local-src
 src.javafx.dir       = ${project.root}/application/sis-javafx/doc
+test.javafx.dir      = ${project.root}/application/sis-javafx/doc
 src.webapp.dir       = ${project.root}/application/sis-webapp/src/main/java
 test.webapp.dir      = ${project.root}/application/sis-webapp/src/test/java
 src.console.dir      = ${project.root}/application/sis-console/src/main/java
diff --git a/ide-project/NetBeans/nbproject/project.xml b/ide-project/NetBeans/nbproject/project.xml
index 7984b5c..1c0d1e1 100644
--- a/ide-project/NetBeans/nbproject/project.xml
+++ b/ide-project/NetBeans/nbproject/project.xml
@@ -46,6 +46,7 @@
                 <root id="src.c.gdal.dir" name="GDAL/Proj4 JNI"/>
             </source-roots>
             <test-roots>
+                <root id="test.javafx.dir" name="Test JavaFX application"/>
                 <root id="test.webapp.dir" name="Test web application"/>
                 <root id="test.console.dir" name="Test Console"/>
                 <root id="test.portrayal.dir" name="Test Portrayal"/>
@@ -89,6 +90,7 @@
             <word>classname</word>
             <word>classnames</word>
             <word>classpath</word>
+            <word>complexify</word>
             <word>deserialization</word>
             <word>deserialized</word>
             <word>endianness</word>