sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] 02/02: Provide a contextual menu for generating isolines at a constant interval in a range. This work required a refactoring of the way we validate `TextField` value in order to share code.
Date Mon, 01 Feb 2021 23:31:14 GMT
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

commit b389939db882693c833f94ddd4930745e7091b9e
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Mon Feb 1 23:48:21 2021 +0100

    Provide a contextual menu for generating isolines at a constant interval in a range.
    This work required a refactoring of the way we validate `TextField` value in order to
share code.
---
 .../main/java/org/apache/sis/gui/DataViewer.java   |   5 +-
 .../org/apache/sis/gui/coverage/CellFormat.java    |   9 +-
 .../apache/sis/gui/coverage/CoverageControls.java  |   2 +-
 .../org/apache/sis/internal/gui/Resources.java     |  16 ++
 .../apache/sis/internal/gui/Resources.properties   |   3 +
 .../sis/internal/gui/Resources_fr.properties       |   3 +
 .../java/org/apache/sis/internal/gui/Styles.java   |  16 +-
 .../apache/sis/internal/gui/control/ColorCell.java |   7 +-
 .../sis/internal/gui/control/FormatApplicator.java | 234 +++++++++++++++++++++
 .../sis/internal/gui/control/FormatTableCell.java  | 108 ++--------
 .../sis/internal/gui/control/ValueColorMapper.java | 171 +++++++++++++--
 .../org/apache/sis/gui/pseudo-classes.css          |   6 +
 .../internal/gui/control/ValueColorMapperApp.java  |  12 +-
 .../org/apache/sis/util/resources/Vocabulary.java  |   5 +
 .../sis/util/resources/Vocabulary.properties       |   1 +
 .../sis/util/resources/Vocabulary_fr.properties    |   1 +
 ide-project/NetBeans/build.xml                     |   1 +
 17 files changed, 481 insertions(+), 119 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 a9d622c..08d9bbc 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
@@ -42,6 +42,7 @@ import org.apache.sis.internal.gui.BackgroundThreads;
 import org.apache.sis.internal.gui.LogHandler;
 import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.internal.gui.RecentChoices;
+import org.apache.sis.internal.gui.Styles;
 import org.apache.sis.internal.storage.Capability;
 import org.apache.sis.internal.storage.StoreMetadata;
 import org.apache.sis.storage.DataStoreProvider;
@@ -175,11 +176,13 @@ public class DataViewer extends Application {
         final BorderPane pane = new BorderPane();
         pane.setTop(menus);
         pane.setCenter(content.getView());
+        final Scene scene = new Scene(pane);
+        scene.getStylesheets().add(Styles.STYLESHEET);
         final Rectangle2D bounds = Screen.getPrimary().getVisualBounds();
         window.setTitle("Apache Spatial Information System");
         window.getIcons().addAll(new Image(DataViewer.class.getResourceAsStream("SIS_64px.png")),
                                  new Image(DataViewer.class.getResourceAsStream("SIS_128px.png")));
-        window.setScene(new Scene(pane));
+        window.setScene(scene);
         window.setWidth (0.75 * bounds.getWidth());
         window.setHeight(0.75 * bounds.getHeight());
         window.show();
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CellFormat.java
b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CellFormat.java
index cdb24de..67b67a5 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CellFormat.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CellFormat.java
@@ -26,7 +26,6 @@ import java.awt.image.RenderedImage;
 import javafx.beans.property.SimpleStringProperty;
 import javafx.scene.control.ComboBox;
 import javafx.scene.control.Tooltip;
-import javafx.scene.layout.Background;
 import javafx.util.Duration;
 import org.apache.sis.image.PlanarImage;
 import org.apache.sis.math.DecimalFunctions;
@@ -162,16 +161,16 @@ final class CellFormat extends SimpleStringProperty {
      */
     private void onPatternSelected(final ComboBox<String> choices, final String newValue)
{
         if (!isAdjusting) {
-            Background background;
+            boolean error;
             String message;
             try {
                 isAdjusting = true;
                 setValue(newValue);
-                background = null;
                 message = null;
+                error = false;
             } catch (IllegalArgumentException e) {
-                background = Styles.ERROR_BACKGROUND;
                 message = e.getLocalizedMessage();
+                error = true;
             } finally {
                 isAdjusting = false;
             }
@@ -186,7 +185,7 @@ final class CellFormat extends SimpleStringProperty {
                 }
             }
             choices.setTooltip(tooltip);
-            choices.getEditor().setBackground(background);
+            choices.getEditor().pseudoClassStateChanged(Styles.ERROR, error);
         }
     }
 
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 2fce8fa..fe7060b 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
@@ -149,7 +149,7 @@ final class CoverageControls extends Controls {
          */
         final VBox isolinesPane;
         {   // Block for making variables locale to this scope.
-            final ValueColorMapper mapper = new ValueColorMapper(vocabulary);
+            final ValueColorMapper mapper = new ValueColorMapper(resources, vocabulary);
             isolines = new IsolineRenderer(view);
             isolines.setIsolineTables(java.util.Collections.singletonList(mapper.getSteps()));
             isolinesPane = new VBox(mapper.getView());              // TODO: add band selector
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.java
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.java
index 677f245..c983d43 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.java
@@ -121,6 +121,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short CenteredProjection = 43;
 
         /**
+         * Clear all
+         */
+        public static final short ClearAll = 55;
+
+        /**
          * Close
          */
         public static final short Close = 10;
@@ -226,6 +231,12 @@ public final class Resources extends IndexedResourceBundle {
         public static final short InconsistencyIn_2 = 39;
 
         /**
+         * Generate isolines at constant interval
+         * starting from given minimum.
+         */
+        public static final short IsolinesInRange = 57;
+
+        /**
          * Loading…
          */
         public static final short Loading = 24;
@@ -271,6 +282,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short Orthographic = 52;
 
         /**
+         * Range of values…
+         */
+        public static final short RangeOfValues = 56;
+
+        /**
          * Select a coordinate reference system
          */
         public static final short SelectCRS = 30;
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.properties
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.properties
index 8c2abfe..179baf0 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.properties
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources.properties
@@ -33,6 +33,7 @@ CanNotReadResource     = A resource contained in the file can not be read.
The c
 CanNotRender           = An error occurred while rendering the data.
 CanNotUseRefSys_1      = Can not use the \u201c{0}\u201d reference system.
 CenteredProjection     = Centered projection
+ClearAll               = Clear all
 Close                  = Close
 Copy                   = Copy
 CopyAs                 = Copy as
@@ -54,6 +55,7 @@ GeospatialFiles        = Geospatial data files
 Help                   = Help
 ImageStart             = Image start
 InconsistencyIn_2      = {0} \u2013 inconsistency in `{1}` property
+IsolinesInRange        = Generate isolines at constant interval\nstarting from given minimum.
 Loading                = Loading\u2026
 Mercator               = Mercator
 MainWindow             = Main window
@@ -63,6 +65,7 @@ Open                   = Open\u2026
 OpenDataFile           = Open data file
 OpenRecentFile         = Open recent file
 Orthographic           = Orthographic
+RangeOfValues          = Range of values\u2026
 SelectCRS              = Select a coordinate reference system
 SelectCrsByContextMenu = For changing the projection, use contextual menu on the map.
 SendTo                 = Send to
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources_fr.properties
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources_fr.properties
index b4ee8fa..f13048f 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources_fr.properties
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/Resources_fr.properties
@@ -38,6 +38,7 @@ CanNotReadResource     = Une ressource contenue dans le fichier ne peut
pas \u00
 CanNotRender           = Une erreur est survenue lors de l\u2019affichage des donn\u00e9es.
 CanNotUseRefSys_1      = Ne peut pas utiliser le syst\u00e8me de r\u00e9f\u00e9rence \u00ab\u202f{0}\u202f\u00bb.
 CenteredProjection     = Projection centr\u00e9e
+ClearAll               = Effacer tout
 Close                  = Fermer
 Copy                   = Copier
 CopyAs                 = Copier comme
@@ -59,6 +60,7 @@ GeospatialFiles        = Fichiers de donn\u00e9es g\u00e9ospatiales
 Help                   = Aide
 ImageStart             = D\u00e9but de l\u2019image
 InconsistencyIn_2      = {0} \u2013 incoh\u00e9rence dans la propri\u00e9t\u00e9 `{1}`
+IsolinesInRange        = G\u00e9n\u00e8re des isolignes \u00e0 intervalle constant en\ncommen\u00e7ant
\u00e0 la valeur minimale sp\u00e9cifi\u00e9e.
 Loading                = Chargement\u2026
 Mercator               = Mercator
 MainWindow             = Fen\u00eatre principale
@@ -68,6 +70,7 @@ Open                   = Ouvrir\u2026
 OpenDataFile           = Ouvrir un fichier de donn\u00e9es
 OpenRecentFile         = Ouvrir un fichier r\u00e9cent
 Orthographic           = Orthographique
+RangeOfValues          = Plage de valeurs\u2026
 SelectCRS              = Choisir un syst\u00e8me de r\u00e9f\u00e9rence des coordonn\u00e9es
 SelectCrsByContextMenu = Pour changer la projection, utilisez le menu contextuel sur la carte.
 SendTo                 = Envoyer vers
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 f673c46..075303f 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
@@ -19,13 +19,12 @@ package org.apache.sis.internal.gui;
 import java.util.Arrays;
 import java.io.IOException;
 import java.io.InputStream;
+import javafx.css.PseudoClass;
 import javafx.geometry.Insets;
 import javafx.scene.Node;
 import javafx.scene.control.Label;
 import javafx.scene.paint.Color;
 import javafx.scene.image.Image;
-import javafx.scene.layout.Background;
-import javafx.scene.layout.BackgroundFill;
 import javafx.scene.layout.ColumnConstraints;
 import javafx.scene.layout.GridPane;
 import javafx.scene.layout.Priority;
@@ -49,6 +48,13 @@ import org.apache.sis.util.Static;
  */
 public final class Styles extends Static {
     /**
+     * Path to the CSS file defining pseudo-classes. This is the file defining appearance
+     * of controls in some situation defining by pseudo-classes, for example when a text
+     * field is flagged with {@link #ERROR}.
+     */
+    public static final String STYLESHEET = "org/apache/sis/gui/pseudo-classes.css";
+
+    /**
      * Approximate size of vertical scroll bar.
      */
     public static final int SCROLLBAR_WIDTH = 20;
@@ -109,10 +115,10 @@ public final class Styles extends Static {
     public static final Color SELECTION_BACKGROUND = Color.LIGHTBLUE;
 
     /**
-     * The background for cell having an illegal input value.
+     * Identifies the CSS pseudo-class from {@code "org/apache/sis/gui/stylesheet.css"}
+     * to apply if a {@link javafx.scene.control.TextInputControl} has an invalid value.
      */
-    public static final Background ERROR_BACKGROUND =
-            new Background(new BackgroundFill(Color.LIGHTPINK, null, null));
+    public static final PseudoClass ERROR = PseudoClass.getPseudoClass("error");
 
     /**
      * The Unicode character to put in a button for requesting more information about an
error.
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java
index 139f56f..dc3f011 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java
@@ -29,6 +29,7 @@ import javafx.scene.control.ListCell;
 import javafx.scene.control.TableCell;
 import javafx.scene.control.TableRow;
 import javafx.scene.control.TableView;
+import javafx.scene.input.MouseButton;
 import javafx.scene.input.MouseEvent;
 import javafx.scene.paint.Color;
 import javafx.scene.paint.Paint;
@@ -111,8 +112,10 @@ final class ColorCell<S> extends TableCell<S,ColorRamp> implements
EventHandler<
      * transitions to editing state. It has the effect of showing the color picker or color
ramp chooser.
      */
     private static void mouseClicked(final MouseEvent event) {
-        if (((ColorCell<?>) event.getSource()).requestEdit()) {
-            event.consume();
+        if (event.getButton() == MouseButton.PRIMARY) {
+            if (((ColorCell<?>) event.getSource()).requestEdit()) {
+                event.consume();
+            }
         }
     }
 
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/FormatApplicator.java
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/FormatApplicator.java
new file mode 100644
index 0000000..027e575
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/FormatApplicator.java
@@ -0,0 +1,234 @@
+/*
+ * 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.control;
+
+import java.text.Format;
+import java.text.ParsePosition;
+import java.text.ParseException;
+import javafx.util.StringConverter;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.scene.control.TextField;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.beans.property.ReadOnlyProperty;
+import javafx.beans.InvalidationListener;
+import org.apache.sis.internal.gui.Styles;
+import org.apache.sis.util.CharSequences;
+
+
+/**
+ * Parses and formats {@link TextField} content with a {@link Format}.
+ * The same {@code FormatApplicator} can be used for many {@link TextField} instances.
+ *
+ * <p>The interfaces implemented by this classes are for registering listeners on
+ * {@link TextField} instances. The set of interfaces may change in any future version.
+ * Registrations should be done by calls to {@link #setListenersOn(TextField)} only.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ *
+ * @param  <T>  the type of objects expected and returned by {@link #format}.
+ *
+ * @since 1.1
+ * @module
+ */
+final class FormatApplicator<T> extends StringConverter<T>
+        implements EventHandler<ActionEvent>, ChangeListener<Boolean>
+{
+    /**
+     * The type of objects expected and returned by {@link #format}.
+     */
+    private final Class<T> valueType;
+
+    /**
+     * The format to use for parsing and formatting {@code <T>} values.
+     * The same instance can be shared by all cells in a table.
+     */
+    final Format format;
+
+    /**
+     * Listener to notify when a {@link TextField} value changed.
+     * We track only the {@link TextField} instances given to {@link #setListenersOn(TextField)}.
+     */
+    InvalidationListener listener;
+
+    /**
+     * Creates a new handler for parsing and formatting values of the given type.
+     *
+     * @param valueType  the type of objects expected and returned by {@code format}.
+     * @param format     the format to use for parsing and formatting {@code <T>} values.
+     */
+    public FormatApplicator(final Class<T> valueType, final Format format) {
+        this.valueType = valueType;
+        this.format    = format;
+    }
+
+    /**
+     * Sets listeners on the given editor. The text will be parsed when the field lost focus
or when
+     * user presses "Enter" and the result will be stored using {@link TextField#setUserData(Object)}.
+     *
+     * @param  editor  the editor on which to set listeners.
+     */
+    public final void setListenersOn(final TextField editor) {
+        editor.focusedProperty().addListener(this);
+        editor.setOnAction(this);
+    }
+
+    /**
+     * Returns {@code true} if the given item is null or {@link Double#NaN}.
+     * Future version may give some control on the values to filter, if there is a need.
+     */
+    private static boolean isNil(final Object item) {
+        return (item == null) || ((item instanceof Double) && ((Double) item).isNaN());
+    }
+
+    /**
+     * Returns the given item as text, or {@code null} if none.
+     * Current implementation does not format {@link Double#NaN} values.
+     * Future version may give some control on the values to filter, if there is a need.
+     *
+     * @param  item    the value to format, or {@code null}.
+     * @return formatted value, or {@code null} if the given item is null or NaN.
+     */
+    @Override
+    public final String toString(final T item) {
+        return isNil(item) ? null : format.format(item);
+    }
+
+    /**
+     * Formats the given item as text and write the result in the given editor.
+     *
+     * @param  editor  the editor where to write the formatted value.
+     * @param  item    the value to format, or {@code null}.
+     */
+    public final void format(final TextField editor, final T item) {
+        editor.setText(toString(item));
+        setErrorFlag(editor, false);
+    }
+
+    /**
+     * Parses the given text. This method is defined for compliance with {@link StringConverter}
+     * contract, but {@link #parse(TextField)} should be used instead.
+     *
+     * @param  text  the text to parse, or {@code null}.
+     * @return the parsed value, or {@code null} if the given text was null.
+     * @throws IllegalArgumentException if the given text can not be parsed.
+     */
+    @Override
+    public final T fromString(String text) {
+        if (text == null || (text = text.trim()).isEmpty()) {
+            return null;
+        }
+        try {
+            return valueType.cast(format.parseObject(text));
+        } catch (ParseException e) {
+            throw new IllegalArgumentException(e);
+        }
+    }
+
+    /**
+     * Parses the given editor content and, if the parsing is successful, returns the value.
+     * If the parsing failed, the cell background color is changed and the caret is moved
to
+     * the error position.
+     *
+     * @param   editor  the editor containing the text to parse.
+     * @return  the parsed value, or {@code null} if parsing failed.
+     */
+    public final T parse(final TextField editor) {
+        String text = editor.getText();
+        if (text != null) {
+            final int end = CharSequences.skipTrailingWhitespaces(text, 0, text.length());
+            final int start = CharSequences.skipLeadingWhitespaces(text, 0, end);
+            if (start < end) {
+                final ParsePosition pos = new ParsePosition(start);
+                final T value = valueType.cast(format.parseObject(text, pos));
+                final int stop = pos.getIndex();
+                if (stop >= end && !isNil(value)) {
+                    setErrorFlag(editor, false);
+                    return value;
+                }
+                editor.positionCaret(value != null ? stop : pos.getErrorIndex());
+            }
+        }
+        /*
+         * If `format` did not used all characters, either we have a parsing error
+         * or the last characters have been ignored (which we consider as an error).
+         * The 2 cases can be distinguished by `value` being null or not.
+         */
+        if (text != null && !text.isEmpty()) {
+            setErrorFlag(editor, true);
+        }
+        return null;
+    }
+
+    /**
+     * Parses the given editor content and stores the result as a user object.
+     */
+    private void parseAndStore(final TextField editor) {
+        final T newValue = parse(editor);
+        editor.setUserData(newValue);
+        if (listener != null) {
+            listener.invalidated(editor.getProperties());
+        }
+    }
+
+    /**
+     * Invoked when user presses {@code Enter} in a {@link TextField}.
+     * This method is public as a listener implementation side effect
+     * and should not be invoked directly.
+     *
+     * @param  event  information about the event, such as the source text field.
+     *
+     * @see #setListenersOn(TextField)
+     */
+    @Override
+    public void handle(final ActionEvent event) {
+        parseAndStore((TextField) event.getSource());
+    }
+
+    /**
+     * Invoked when a {@link TextField} get or lost focus.
+     * This method is public as a listener implementation side effect
+     * and should not be invoked directly.
+     *
+     * @param  property  the {@link TextField#focusedProperty()}.
+     * @param  oldValue  the old "is focused" value.
+     * @param  newValue  the new "is focused" value.
+     *
+     * @see #setListenersOn(TextField)
+     */
+    @Override
+    public void changed(final ObservableValue<? extends Boolean> property, final Boolean
oldValue, final Boolean newValue) {
+        final TextField editor = (TextField) ((ReadOnlyProperty<?>) property).getBean();
+        if (newValue) {
+            setErrorFlag(editor, false);
+        } else {
+            parseAndStore(editor);
+        }
+    }
+
+    /**
+     * Declares whether content of given editor has an error.
+     *
+     * @param  editor  the editor on which to set the error flag.
+     * @param  flag    {@code true} if editor content has an error, or {@code false} if valid.?
+     */
+    private static void setErrorFlag(final TextField editor, final boolean flag) {
+        editor.pseudoClassStateChanged(Styles.ERROR, flag);
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/FormatTableCell.java
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/FormatTableCell.java
index fd02025..d176575 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/FormatTableCell.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/FormatTableCell.java
@@ -17,7 +17,6 @@
 package org.apache.sis.internal.gui.control;
 
 import java.text.Format;
-import java.text.ParsePosition;
 import java.text.DecimalFormat;
 import java.text.DecimalFormatSymbols;
 import javafx.geometry.Pos;
@@ -29,9 +28,7 @@ import javafx.scene.control.TableColumn;
 import javafx.scene.control.TablePosition;
 import javafx.scene.control.TableView;
 import javafx.scene.control.TextField;
-import javafx.scene.layout.Background;
 import org.apache.sis.internal.gui.Styles;
-import org.apache.sis.util.CharSequences;
 
 
 /**
@@ -54,103 +51,36 @@ import org.apache.sis.util.CharSequences;
  */
 final class FormatTableCell<S,T> extends TableCell<S,T> {
     /**
-     * The type of objects expected and returned by {@link #format}.
-     */
-    private final Class<T> valueType;
-
-    /**
      * The format to use for parsing and formatting {@code <T>} values.
      * The same instance can be shared by all cells in a table.
      */
-    private final Format format;
+    private final FormatApplicator<T> textConverter;
 
     /**
-     * The control to use during edition.
+     * The control to use during edition. Created when first needed.
      */
     private TextField editor;
 
     /**
-     * The {@link #editor} background to restore after successful parsing or cancellation.
-     * This is non-null only if the background has been changed for signaling a parsing error.
-     */
-    private Background backgroundToRestore;
-
-    /**
      * A listener for enabling automatic transition to insertion state when a digit is pressed,
      * or {@code null} if none. The same instance is shared by all cells in the same column.
      */
-    private final Trigger<S> trigger;
+    private final Trigger<S> insertTrigger;
 
     /**
      * Creates a new table cell for parsing and formatting values of the given type.
      *
-     * @param valueType  the type of objects expected and returned by {@code format}.
-     * @param format     the format to use for parsing and formatting {@code <T>} values.
-     * @param trigger    listener for automatic transition to insertion state when a digit
is pressed,
-     *                   or {@code null} if none.
+     * @param textConverter  the format to use for parsing and formatting {@code <T>}
values.
+     * @param insertTrigger  listener for automatic transition to insertion state when a
digit is pressed,
+     *                       or {@code null} if none.
      */
-    public FormatTableCell(final Class<T> valueType, final Format format, final Trigger<S>
trigger) {
-        this.valueType = valueType;
-        this.format    = format;
-        this.trigger   = trigger;
+    public FormatTableCell(final FormatApplicator<T> textConverter, final Trigger<S>
insertTrigger) {
+        this.textConverter = textConverter;
+        this.insertTrigger = insertTrigger;
         setAlignment(Pos.CENTER_LEFT);
     }
 
     /**
-     * Returns {@code true} if the given item is null or {@link Double#NaN}.
-     * Future version may give some control on the values to filter, if there is a need.
-     */
-    private static boolean isNil(final Object item) {
-        return (item == null) || ((item instanceof Double) && ((Double) item).isNaN());
-    }
-
-    /**
-     * Returns the given item as text, or {@code null} if none. The text will be given
-     * to different control depending on whether this cell is in editing state or not.
-     *
-     * <p>Current implementation does not format {@link Double#NaN} values.
-     * Future version may give some control on the values to filter, if there is a need.</p>
-     */
-    private String format(final T item) {
-        return isNil(item) ? null : format.format(item);
-    }
-
-    /**
-     * Parses the current editor content and, if the parsing is successful, commit.
-     * If the parsing failed, the cell background color is changed and the caret is
-     * moved to the error position.
-     */
-    private void parseAndCommit() {
-        String text = editor.getText();
-        if (text != null) {
-            final int end = CharSequences.skipTrailingWhitespaces(text, 0, text.length());
-            final int start = CharSequences.skipLeadingWhitespaces(text, 0, end);
-            if (start < end) {
-                final ParsePosition pos = new ParsePosition(start);
-                final T value = valueType.cast(format.parseObject(text, pos));
-                final int stop = pos.getIndex();
-                if (stop >= end && !isNil(value)) {
-                    commitEdit(value);
-                    return;
-                }
-                editor.positionCaret(value != null ? stop : pos.getErrorIndex());
-            }
-        }
-        /*
-         * If `format` did not used all characters, either we have a parsing error
-         * or the last characters have been ignored (which we consider as an error).
-         * The 2 cases can be distinguished by `value` being null or not.
-         */
-        if (backgroundToRestore == null) {
-            backgroundToRestore = editor.getBackground();
-            if (backgroundToRestore == null) {
-                backgroundToRestore = Background.EMPTY;
-            }
-        }
-        editor.setBackground(Styles.ERROR_BACKGROUND);
-    }
-
-    /**
      * Invoked when a new value needs to be shown in the cell. The new value will be formatted
in either
      * the {@linkplain #editor} or in the label, depending if this cell is in editing state
or not.
      *
@@ -166,10 +96,10 @@ final class FormatTableCell<S,T> extends TableCell<S,T> {
             if (isEditing()) {
                 g = editor;
                 if (g != null) {
-                    g.setText(format(item));
+                    textConverter.format(g, item);
                 }
             } else if (item != null) {
-                text = format(item);
+                text = textConverter.toString(item);
             }
         }
         setText(text);
@@ -184,23 +114,23 @@ final class FormatTableCell<S,T> extends TableCell<S,T>
{
     @Override
     public void startEdit() {
         super.startEdit();
-        String text = (trigger != null) ? trigger.initialText : null;
+        String text = (insertTrigger != null) ? insertTrigger.initialText : null;
         if (text == null) text = getText();
         if (editor != null) {
             /*
              * If the editor background color has been changed because of an error,
              * restores the normal background.
              */
-            if (backgroundToRestore != null) {
-                editor.setBackground(backgroundToRestore);
-                backgroundToRestore = null;
-            }
+            editor.pseudoClassStateChanged(Styles.ERROR, false);
             editor.setText(text);
         } else {
             editor = new TextField(text);
             editor.setOnAction((event) -> {
                 event.consume();
-                parseAndCommit();
+                final T value = textConverter.parse(editor);
+                if (value != null) {
+                    commitEdit(value);
+                }
             });
             editor.setOnKeyReleased((event) -> {
                 if (event.getCode() == KeyCode.ESCAPE) {
@@ -212,7 +142,7 @@ final class FormatTableCell<S,T> extends TableCell<S,T> {
         setText(null);
         setGraphic(editor);
         editor.requestFocus();
-        if (trigger.initialText == null) {
+        if (insertTrigger.initialText == null) {
             editor.selectAll();
         } else {
             editor.deselect();
@@ -228,7 +158,7 @@ final class FormatTableCell<S,T> extends TableCell<S,T> {
     public void cancelEdit() {
         super.cancelEdit();
         setGraphic(null);
-        setText(format(getItem()));
+        setText(textConverter.toString(getItem()));
     }
 
     /**
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ValueColorMapper.java
b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ValueColorMapper.java
index 0c09e9c..553399d 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ValueColorMapper.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ValueColorMapper.java
@@ -17,6 +17,7 @@
 package org.apache.sis.internal.gui.control;
 
 import java.util.Objects;
+import java.util.Locale;
 import java.text.NumberFormat;
 import javafx.beans.property.BooleanProperty;
 import javafx.beans.property.DoubleProperty;
@@ -26,15 +27,26 @@ import javafx.beans.property.SimpleDoubleProperty;
 import javafx.beans.property.SimpleObjectProperty;
 import javafx.beans.value.ObservableValue;
 import javafx.collections.ObservableList;
+import javafx.scene.Node;
 import javafx.scene.paint.Color;
 import javafx.scene.input.KeyCode;
 import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.GridPane;
+import javafx.scene.control.Label;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.ColorPicker;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.Dialog;
+import javafx.scene.control.DialogPane;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.TextField;
 import javafx.scene.control.TableView;
 import javafx.scene.control.TableColumn;
 import javafx.scene.control.cell.CheckBoxTableCell;
-import javafx.scene.layout.Region;
-import org.apache.sis.internal.gui.Styles;
 import org.apache.sis.internal.util.Numerics;
+import org.apache.sis.internal.gui.Styles;
+import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.util.resources.Vocabulary;
 import org.apache.sis.gui.Widget;
 
@@ -151,10 +163,10 @@ public final class ValueColorMapper extends Widget {
     }
 
     /**
-     * The format to use for formatting numerical values.
-     * The same instance will be shared by all {@link FormatTableCell}s in this table.
+     * Helper for parsing and formatting numerical values in {@link TextField}s.
+     * The same instance will be shared by all {@linkplain #table} cells.
      */
-    private final NumberFormat format;
+    private final FormatApplicator<Number> textConverter;
 
     /**
      * The table showing values associated to colors.
@@ -162,14 +174,31 @@ public final class ValueColorMapper extends Widget {
     private final TableView<Step> table;
 
     /**
+     * The dialog for specifying a range of values with increment.
+     * This is created when first needed if user selects "Range of values" menu item.
+     *
+     * @see #insertRangeOfValues()
+     */
+    private Dialog<Range> rangeEditor;
+
+    /**
      * Creates a new "value-color mapper" widget.
      *
+     * @param  resources   localized resources, given because already known by the caller.
      * @param  vocabulary  localized resources, given because already known by the caller
-     *                     (this argument would be removed if this constructor was public
API).
+     *                     (those arguments would be removed if this constructor was public
API).
      */
-    public ValueColorMapper(final Vocabulary vocabulary) {
-        format = NumberFormat.getInstance();
-        table  = createIsolineTable(vocabulary);
+    public ValueColorMapper(final Resources resources, final Vocabulary vocabulary) {
+        textConverter = new FormatApplicator<>(Number.class, NumberFormat.getInstance());
+        table = createIsolineTable(vocabulary);
+        final MenuItem rangeMenu = new MenuItem(resources.getString(Resources.Keys.RangeOfValues));
+        final MenuItem clearAll  = new MenuItem(resources.getString(Resources.Keys.ClearAll));
+        rangeMenu.setOnAction((e) -> insertRangeOfValues());
+        clearAll .setOnAction((e) -> {
+            final ObservableList<Step> steps = getSteps();
+            steps.remove(0, steps.size() - 1);                  // Keep insertion row, which
is last.
+        });
+        table.setContextMenu(new ContextMenu(rangeMenu, clearAll));
     }
 
     /**
@@ -268,7 +297,7 @@ public final class ValueColorMapper extends Widget {
         final int size = items.size() - 1;                  // Excluding insertion row.
         while (++dst < size) {
             // No `!` for continuing until the end if `value` is NaN.
-            if (dst != row && items.get(dst).value.get() >= value) break;
+            if (dst != row && items.get(dst).value.get() > value) break;
         }
         if (dst != row) {
             if (dst >= row) dst--;
@@ -352,11 +381,11 @@ public final class ValueColorMapper extends Widget {
          * The number can be edited using a `NumberFormat` in current locale.
          */
         final TableColumn<Step,Number> level = new TableColumn<>(vocabulary.getString(Vocabulary.Keys.Level));
-        final FormatTableCell.Trigger<Step> trigger = new FormatTableCell.Trigger<>(level,
format);
-        level.setCellFactory((column) -> new FormatTableCell<>(Number.class, format,
trigger));
+        final FormatTableCell.Trigger<Step> trigger = new FormatTableCell.Trigger<>(level,
textConverter.format);
+        level.setCellFactory((column) -> new FormatTableCell<>(textConverter, trigger));
         level.setCellValueFactory((cell) -> cell.getValue().value);
         level.setOnEditCommit(ValueColorMapper::commitEdit);
-        level.setSortable(false);                           // We will do our own sorting.
+        level.setSortable(false);                               // We will do our own sorting.
         level.setId("level");
         /*
          * Create the table with above "category name" column (read-only),
@@ -390,4 +419,120 @@ public final class ValueColorMapper extends Widget {
             }
         }
     }
+
+    /**
+     * Shows a dialog box for generating values at a fixed interval in a range.
+     * This dialog box is shown by the "Range of values" contextual menu item.
+     */
+    private void insertRangeOfValues() {
+        if (rangeEditor == null) {
+            rangeEditor = Range.createDialog(textConverter, table);
+        }
+        rangeEditor.showAndWait().ifPresent((r) -> {
+            final ObservableList<Step> steps = getSteps();
+            int position = 0;
+increment:  for (double i=0, value; (value = i*r.interval + r.minimum) <= r.maximum; i++)
{    // TODO: use Math.fma with JDK9.
+                while (position < steps.size()) {
+                    final double existing = steps.get(position).value.get();
+                    if (existing == value) continue increment;
+                    if (!(existing <= value)) break;            // Stop also on `existing
= NaN` (the insertion row).
+                    position++;
+                }
+                steps.add(position, new Step(value, r.color));
+            }
+        });
+    }
+
+    /**
+     * The range of values and constant interval at which to create values associated to
colors.
+     */
+    private static final class Range {
+        /**
+         * The bounds and interval of values to create.
+         */
+        final double minimum, maximum, interval;
+
+        /**
+         * The constant color to associate with all values.
+         */
+        final Color color;
+
+        /**
+         * Creates a new range.
+         */
+        Range(final double minimum, final double maximum, final double interval, final Color
color) {
+            this.minimum  = minimum;
+            this.maximum  = maximum;
+            this.interval = interval;
+            this.color    = color;
+        }
+
+        /**
+         * Creates a dialog box for generating a range of values at constant interval.
+         * This is invoked the first time that {@link ValueColorMapper#rangeEditor} is needed.
+         */
+        static Dialog<Range> createDialog(final FormatApplicator<Number> textConverter,
final Node owner) {
+            final Vocabulary  vocabulary   = Vocabulary.getResources((Locale) null);
+            final TextField   minimum      = new TextField();
+            final TextField   maximum      = new TextField();
+            final TextField   interval     = new TextField();
+            final ColorPicker colorInRange = new ColorPicker(Color.BLACK);
+            colorInRange.setMaxWidth(Double.MAX_VALUE);
+            final GridPane content = Styles.createControlGrid(0,
+                    createRow(minimum,      vocabulary, Vocabulary.Keys.Minimum),
+                    createRow(maximum,      vocabulary, Vocabulary.Keys.Maximum),
+                    createRow(interval,     vocabulary, Vocabulary.Keys.Interval),
+                    createRow(colorInRange, vocabulary, Vocabulary.Keys.Color));
+
+            final Dialog<Range> rangeEditor = new Dialog<>();
+            rangeEditor.initOwner(owner.getScene().getWindow());
+            rangeEditor.setTitle(vocabulary.getString(Vocabulary.Keys.Isolines));
+            rangeEditor.setHeaderText(Resources.format(Resources.Keys.IsolinesInRange));
+            final DialogPane pane = rangeEditor.getDialogPane();
+            pane.setContent(content);
+            pane.getButtonTypes().setAll(ButtonType.APPLY, ButtonType.CANCEL);
+            final Node apply = pane.lookupButton(ButtonType.APPLY);
+            apply.setDisable(true);
+            minimum.requestFocus();
+            /*
+             * Following listeners will parse values when the field lost focus or when user
presses "Enter" key.
+             * The field text will get a light red background if the value is unparseable.
The "Apply" button is
+             * disabled until all values become valid.
+             */
+            textConverter.setListenersOn(minimum);
+            textConverter.setListenersOn(maximum);
+            textConverter.setListenersOn(interval);
+            textConverter.listener = (p) -> {
+                final boolean isValid = valueOf(maximum) >= valueOf(minimum) &&
valueOf(interval) > 0;
+                apply.setDisable(!isValid);
+            };
+            rangeEditor.setResultConverter((button) -> {
+                if (button == ButtonType.APPLY) {
+                    return new Range(valueOf(minimum), valueOf(maximum), valueOf(interval),
colorInRange.getValue());
+                }
+                return null;
+            });
+            return rangeEditor;
+        }
+
+        /**
+         * Creates one of the rows (minimum, maximum or increment) label to show in dialog
box.
+         * The label are associated to a {@link TextField} or {@link ColorPicker}.
+         */
+        private static Label createRow(final Node editor, final Vocabulary vocabulary, final
short key) {
+            final Label label = new Label(vocabulary.getLabel(key));
+            label.setLabelFor(editor);
+            return label;
+        }
+
+        /**
+         * Returns the value parsed in the given editor. Parsed values are stored by
+         * {@link FormatApplicator} as user data in the {@link TextField} instances.
+         */
+        private static double valueOf(final TextField editor) {
+            // A ClassCastException below would be a bug in this class.
+            final Number value = (Number) editor.getUserData();
+            return (value != null) ? value.doubleValue() : Double.NaN;
+        }
+    }
 }
diff --git a/application/sis-javafx/src/main/resources/org/apache/sis/gui/pseudo-classes.css
b/application/sis-javafx/src/main/resources/org/apache/sis/gui/pseudo-classes.css
new file mode 100644
index 0000000..52a7d7e
--- /dev/null
+++ b/application/sis-javafx/src/main/resources/org/apache/sis/gui/pseudo-classes.css
@@ -0,0 +1,6 @@
+/*
+ * Styles applying to controls under circumstances defined by `javafx.css.PseudoClass`.
+ */
+.text-input:error {
+  -fx-background-color: lightpink;
+}
diff --git a/application/sis-javafx/src/test/java/org/apache/sis/internal/gui/control/ValueColorMapperApp.java
b/application/sis-javafx/src/test/java/org/apache/sis/internal/gui/control/ValueColorMapperApp.java
index 303a8a9..f72a7ca 100644
--- a/application/sis-javafx/src/test/java/org/apache/sis/internal/gui/control/ValueColorMapperApp.java
+++ b/application/sis-javafx/src/test/java/org/apache/sis/internal/gui/control/ValueColorMapperApp.java
@@ -24,6 +24,8 @@ import javafx.scene.control.Button;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.BorderPane;
 import javafx.application.Application;
+import org.apache.sis.internal.gui.Styles;
+import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.util.resources.Vocabulary;
 
 
@@ -39,7 +41,7 @@ public final strictfp class ValueColorMapperApp extends Application {
     /**
      * Starts the test application.
      *
-     * @param args  ignored.
+     * @param  args  ignored.
      */
     public static void main(final String[] args) {
         launch(args);
@@ -55,8 +57,10 @@ public final strictfp class ValueColorMapperApp extends Application {
         final BorderPane pane = new BorderPane();
         pane.setCenter(createIsolineTable());
         pane.setBottom(new Button("Focus here"));
+        final Scene scene = new Scene(pane);
+        scene.getStylesheets().add(Styles.STYLESHEET);
         window.setTitle("ValueColorMapper Test");
-        window.setScene(new Scene(pane));
+        window.setScene(scene);
         window.setWidth (400);
         window.setHeight(300);
         window.show();
@@ -66,7 +70,9 @@ public final strictfp class ValueColorMapperApp extends Application {
      * Creates a table with arbitrary isolines to show.
      */
     private static Region createIsolineTable() {
-        final ValueColorMapper handler = new ValueColorMapper(Vocabulary.getResources((Locale)
null));
+        final ValueColorMapper handler = new ValueColorMapper(
+                Resources.forLocale(null),
+                Vocabulary.getResources((Locale) null));
         handler.getSteps().setAll(
                 new ValueColorMapper.Step( 10, Color.BLUE),
                 new ValueColorMapper.Step( 25, Color.GREEN),
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.java
b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.java
index fb8a22a..917329c 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.java
@@ -645,6 +645,11 @@ public final class Vocabulary extends IndexedResourceBundle {
         public static final short Interpolation = 231;
 
         /**
+         * Interval
+         */
+        public static final short Interval = 253;
+
+        /**
          * Invalid
          */
         public static final short Invalid = 107;
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.properties
b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.properties
index 2d9f238..92e79d2 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.properties
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.properties
@@ -134,6 +134,7 @@ Information             = Information
 Interpolation           = Interpolation
 Invalid                 = Invalid
 InverseOperation        = Inverse operation
+Interval                = Interval
 Isolines                = Isolines
 JavaExtensions          = Java extensions
 JavaHome                = Java home directory
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary_fr.properties
b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary_fr.properties
index 724644a..d4a7abc 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary_fr.properties
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary_fr.properties
@@ -141,6 +141,7 @@ Information             = Information
 Interpolation           = Interpolation
 Invalid                 = Invalide
 InverseOperation        = Op\u00e9ration inverse
+Interval                = Intervalle
 Isolines                = Isolignes
 JavaExtensions          = Extensions du Java
 JavaHome                = R\u00e9pertoire du Java
diff --git a/ide-project/NetBeans/build.xml b/ide-project/NetBeans/build.xml
index fcaedcf..6cc8993 100644
--- a/ide-project/NetBeans/build.xml
+++ b/ide-project/NetBeans/build.xml
@@ -110,6 +110,7 @@
       <fileset dir="${project.root}/application/sis-javafx/src/main/resources">
         <include name="**/*.fxml"/>
         <include name="**/*.png"/>
+        <include name="**/*.css"/>
       </fileset>
       <fileset dir="${project.root}/application/sis-console/src/main/resources">
         <include name="**/*.properties"/>


Mime
View raw message