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 <[email protected]> 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"/>
