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 bcf37c2a15fd4b46997b74c9ed0d99f8ee864d2a Author: Martin Desruisseaux <[email protected]> AuthorDate: Fri Sep 19 19:36:40 2025 +0200 Bug fix: `GridView` in the JavaFX application was not showing all values. This is a rewrite of the `GridViewSkin` class which was drawing the cells. The new implementation draws the cells itself instead of relying on Table. --- netbeans-project/nbproject/project.xml | 1 + .../org/apache/sis/gui/coverage/CellFormat.java | 63 +- .../main/org/apache/sis/gui/coverage/GridCell.java | 70 -- .../org/apache/sis/gui/coverage/GridControls.java | 8 +- .../org/apache/sis/gui/coverage/GridError.java | 19 +- .../main/org/apache/sis/gui/coverage/GridRow.java | 110 --- .../org/apache/sis/gui/coverage/GridRowSkin.java | 136 --- .../main/org/apache/sis/gui/coverage/GridTile.java | 43 +- .../main/org/apache/sis/gui/coverage/GridView.java | 253 +++--- .../org/apache/sis/gui/coverage/GridViewSkin.java | 944 +++++++++++---------- .../main/org/apache/sis/gui/internal/Styles.java | 17 +- .../org/apache/sis/gui/coverage/GridViewApp.java | 6 +- 12 files changed, 705 insertions(+), 965 deletions(-) diff --git a/netbeans-project/nbproject/project.xml b/netbeans-project/nbproject/project.xml index b642dd80ff..558c4502b4 100644 --- a/netbeans-project/nbproject/project.xml +++ b/netbeans-project/nbproject/project.xml @@ -35,6 +35,7 @@ <word>geospatial</word> <word>Molodensky</word> <word>namespace</word> + <word>programmatically</word> <word>transformative</word> <word>untiled</word> </spellchecker-wordlist> diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CellFormat.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CellFormat.java index 6868d5b765..8d1b562306 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CellFormat.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CellFormat.java @@ -38,7 +38,7 @@ import org.apache.sis.gui.internal.RecentChoices; /** * Formatter for cell values with a number of fraction digits determined from the sample value resolution. * The property value is the localized format pattern as produced by {@link DecimalFormat#toLocalizedPattern()}. - * This property is usually available but not always; see {@link #hasPattern()}. + * This property is usually available but not always, see {@link #hasPattern()}. * * @author Martin Desruisseaux (Geomatys) */ @@ -51,9 +51,9 @@ final class CellFormat extends SimpleStringProperty { /** * The "classic" number format pattern (as opposed to scientific notation). This is non-null only after * {@link #cellFormat} switched to scientific notation and is used for switching back to classic notation. - * This is a workaround for the absence of `DecimalFormat.useScientificNotation(boolean)` method. + * This is a workaround for the absence of {@code DecimalFormat.useScientificNotation(boolean)} method. */ - @Workaround(library="JDK", version="13") + @Workaround(library="JDK", version="24") private String classicFormatPattern; /** @@ -95,6 +95,11 @@ final class CellFormat extends SimpleStringProperty { */ boolean dataTypeIsInteger; + /** + * Whether the pattern has been made shorter by calls to {@link #shorterPattern()}. + */ + private boolean shortenedPattern; + /** * Temporarily set to {@code true} when the user selects or enters a new pattern in a GUI control, then * reset to {@code false} after the new values has been set. This is a safety against recursive calls @@ -142,10 +147,46 @@ final class CellFormat extends SimpleStringProperty { */ @Override public void setValue(final String pattern) { + shortenedPattern = false; if (cellFormat instanceof DecimalFormat) { ((DecimalFormat) cellFormat).applyLocalizedPattern(pattern); updatePropertyValue(); - ((GridView) getBean()).contentChanged(false); + ((GridView) getBean()).updateCellValues(); + } + } + + /** + * Tries to reduce the size of the pattern used for formatting the numbers. + * This method is invoked when the numbers are too large for the cell width. + * + * @return whether the pattern has been made smaller. + */ + final boolean shorterPattern() { + int n = cellFormat.getMaximumFractionDigits() - 1; + if (n >= 0) { + cellFormat.setMaximumFractionDigits(n); + } else if (cellFormat.isGroupingUsed()) { + cellFormat.setGroupingUsed(false); + } else { + return false; + } + shortenedPattern = true; + formatCell(lastValue); + return true; + } + + /** + * Restores the pattern to the value specified in the pattern property. + * This method cancels the effect of {@link #shorterPattern()}. + * This method should be invoked when the cells have been made wider, + * and this change may allow the original pattern to fit in the new size. + */ + final void restorePattern() { + if (shortenedPattern) { + shortenedPattern = false; + if (cellFormat instanceof DecimalFormat) { + ((DecimalFormat) cellFormat).applyLocalizedPattern(getValue()); + } } } @@ -199,7 +240,7 @@ final class CellFormat extends SimpleStringProperty { */ final int min = cellFormat.getMinimumFractionDigits(); final int max = cellFormat.getMaximumFractionDigits(); - final String[] patterns = new String[max + 2]; + final var patterns = new String[max + 2]; patterns[max + 1] = getValue(); cellFormat.setMinimumFractionDigits(max); for (int n=max; n >= 0; n--) { @@ -211,7 +252,7 @@ final class CellFormat extends SimpleStringProperty { /* * Create the combo-box with above patterns and register listeners in both directions. */ - final ComboBox<String> choices = new ComboBox<>(); + final var choices = new ComboBox<String>(); choices.setEditable(true); choices.getItems().setAll(patterns); choices.getSelectionModel().selectFirst(); @@ -222,7 +263,7 @@ final class CellFormat extends SimpleStringProperty { /** * Invoked when the {@link #cellFormat} configuration needs to be updated. - * Callers should invoke {@link GridView#contentChanged(boolean)} after this method. + * Callers should invoke {@link GridView#updateCellValues()} after this method. * * @param image the source image (shall be non-null). * @param band index of the band to show in this grid view. @@ -256,7 +297,7 @@ final class CellFormat extends SimpleStringProperty { /** * Returns the number of fraction digits to use for formatting sample values in the given band of the given image. - * This method use the {@value PlanarImage#SAMPLE_RESOLUTIONS_KEY} property value. + * This method uses the {@value PlanarImage#SAMPLE_RESOLUTIONS_KEY} property value. * * @param image the image from which to get the number of fraction digits. * @param band the band for which to get the number of fraction digits. @@ -276,9 +317,11 @@ final class CellFormat extends SimpleStringProperty { } /** - * Get the desired sample value from the specified tile and formats its string representation. + * Gets the desired sample value from the specified tile and formats its string representation. * As a slight optimization, we reuse the previous string representation if the number is the same. * It may happen in particular with fill values. + * + * @throws IndexOutOfBoundsException if the given pixel coordinates are out of bounds. */ final String format(final Raster tile, final int x, final int y, final int b) { buffer.setLength(0); @@ -286,7 +329,7 @@ final class CellFormat extends SimpleStringProperty { final int integer = tile.getSample(x, y, b); final double value = integer; if (Double.doubleToRawLongBits(value) != Double.doubleToRawLongBits(lastValue)) { - // The `format` method invoked here is not the same as in `double` case. + // The `format` method invoked here is not the same as in the `double` case. lastValueAsText = cellFormat.format(integer, buffer, formatField).toString(); lastValue = value; } diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridCell.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridCell.java deleted file mode 100644 index e4b50c4149..0000000000 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridCell.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.sis.gui.coverage; - -import javafx.scene.control.IndexedCell; -import javafx.scene.control.Skin; -import javafx.scene.control.skin.CellSkinBase; - - -/** - * A single cell in a {@link GridRow}. This cell contains one sample value of one pixel in an image. - * - * @author Martin Desruisseaux (Geomatys) - */ -final class GridCell extends IndexedCell<String> { - /** - * Creates a new cell. - */ - GridCell() { - /* - * In unmanaged mode, the parent (GridRow) will ignore the cell preferred size computations and layout. - * Changes in layout bounds will not trigger relayout above it. This is what we want since the parents - * decide themselves when to layout in our implementation. - */ - setManaged(false); - } - - /** - * Sets the sample value to show in this grid cell. - * Note that the {@code value} may be null even if {@code empty} is false. - * It may happen if the image is still loading in a background thread. - * - * @param value the sample value, or {@code null} if not available. - * @param empty whether this cell is used for filling empty space - * (not to be confused with value not yet available). - */ - @Override - protected void updateItem(String value, final boolean empty) { - super.updateItem(value, empty); - if (value == null) value = ""; - setText(value); - } - - /** - * Creates a new instance of the skin responsible for rendering this grid cell. - * From the perspective of {@link IndexedCell}, the {@link Skin} is a black box. - * It listens and responds to changes in state of this grid cell. - * - * @return the renderer of this grid cell. - */ - @Override - protected Skin<GridCell> createDefaultSkin() { - // We have nothing to add compared to the base implementation. - return new CellSkinBase<>(this); - } -} diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridControls.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridControls.java index fb6ffe5653..b0fb761481 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridControls.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridControls.java @@ -69,8 +69,8 @@ final class GridControls extends ViewAndControls { final VBox displayPane; { // Block for making variables locale to this scope. final GridPane gp = Styles.createControlGrid(0, - label(vocabulary, Vocabulary.Keys.Width, createSlider(view.cellWidth, 30, 200)), - label(vocabulary, Vocabulary.Keys.Height, createSlider(view.cellHeight, 10, 50)), + label(vocabulary, Vocabulary.Keys.Width, createSlider(view.cellWidth)), + label(vocabulary, Vocabulary.Keys.Height, createSlider(view.cellHeight)), label(vocabulary, Vocabulary.Keys.Format, view.cellFormat.createEditor())); Styles.setAllRowToSameHeight(gp); @@ -92,8 +92,8 @@ final class GridControls extends ViewAndControls { * Creates a new slider for the given range of values and bound to the specified properties. * This is used for creating the sliders to show in the "Display" pane. */ - private static Slider createSlider(final DoubleProperty property, final double min, final double max) { - final Slider slider = new Slider(min, max, property.getValue()); + private static Slider createSlider(final DoubleProperty property) { + final var slider = new Slider(20, 120, property.getValue()); property.bind(slider.valueProperty()); slider.setShowTickMarks(false); return slider; diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridError.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridError.java index c17720cbb8..5bdc4fa079 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridError.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridError.java @@ -21,6 +21,8 @@ import javafx.geometry.Pos; import javafx.geometry.Insets; import javafx.scene.control.Button; import javafx.scene.control.Label; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.TilePane; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; @@ -38,6 +40,11 @@ import org.apache.sis.util.resources.Vocabulary; * @author Martin Desruisseaux (Geomatys) */ final class GridError extends VBox { + /** + * The background for error boxes. + */ + private static final Background BACKGROUND = new Background(new BackgroundFill(Color.FLORALWHITE, null, null)); + /** * The tile in error. */ @@ -60,7 +67,7 @@ final class GridError extends VBox { private final Label message; /** - * The zero-based row and column indices of the tile. + * The row and column indices of the tile. * This is computed by {@link GridView#getTileBounds(int, int)} and should be constant. */ private final Rectangle region; @@ -75,9 +82,9 @@ final class GridError extends VBox { this.region = view.getTileBounds(tile.tileX, tile.tileY); this.header = Resources.format(Resources.Keys.CanNotFetchTile_2, tile.tileX, tile.tileY); - final Button details = new Button(Vocabulary.format(Vocabulary.Keys.Details)); - final Button retry = new Button(Vocabulary.format(Vocabulary.Keys.Retry)); - final TilePane buttons = new TilePane(12, 0, details, retry); + final var details = new Button(Vocabulary.format(Vocabulary.Keys.Details)); + final var retry = new Button(Vocabulary.format(Vocabulary.Keys.Retry)); + final var buttons = new TilePane(12, 0, details, retry); buttons.setPrefRows(1); buttons.setPrefColumns(2); buttons.setAlignment(Pos.CENTER); @@ -96,6 +103,7 @@ final class GridError extends VBox { setPadding(new Insets(12, 18, 24, 18)); details.setOnAction((e) -> showDetails()); retry .setOnAction((e) -> retry()); + setBackground(BACKGROUND); } /** @@ -120,8 +128,9 @@ final class GridError extends VBox { * Invoked when the user asked to retry a tile computation. */ private void retry() { - final GridView view = (GridView) getParent(); + final var view = (GridView) getParent(); ((GridViewSkin) view.getSkin()).removeError(this); tile.clear(); + view.requestLayout(); } } diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRow.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRow.java deleted file mode 100644 index 1ee9294ecf..0000000000 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRow.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.sis.gui.coverage; - -import java.awt.image.RenderedImage; -import javafx.scene.control.IndexedCell; -import javafx.scene.control.Skin; -import javafx.scene.control.skin.VirtualFlow; -import javafx.scene.text.Font; -import javafx.scene.text.FontWeight; - - -/** - * A row in a {@link RenderedImage}. This is only a pointer to a row of pixels in an image, - * not a storage for pixel values. The row to be shown is identified by {@link #getIndex()}, - * which is a zero-based index. Note that <var>y</var> coordinates in a {@link RenderedImage} - * do not necessarily starts at 0, so a constant offset may exist between {@link #getIndex()} - * values and image <var>y</var> coordinates. - * - * <p>{@link GridRow} instances are created by JavaFX {@link VirtualFlow}, which is responsible - * for reusing cells. A relatively small number of {@code GridRow} instances should be created - * even if the image contains millions of rows.</p> - * - * <p>The {@code GridRow} index value is zero-based. This is not necessarily the <var>y</var> coordinate - * in the image since {@link RenderedImage} coordinate system do not necessarily starts at zero. - * This value may be outside image bounds, in which case this {@code GridRow} should be rendered as empty. - * - * @author Martin Desruisseaux (Geomatys) - */ -final class GridRow extends IndexedCell<Void> { - /** - * The grid view where this row will be shown. - */ - final GridView view; - - /** - * The <var>y</var> coordinate of the tile in the {@link RenderedImage}. - * Note that those coordinates do not necessarily start at zero; negative values may be valid. - * This value is computed from {@link #getIndex()} value and cached for efficiency. - */ - private int tileY; - - /** - * Invoked by {@link VirtualFlow} when a new cell is needed. - * This constructor is referenced by lambda-function in {@link GridViewSkin}. - */ - GridRow(final VirtualFlow<GridRow> owner) { - view = (GridView) owner.getParent(); - setPrefWidth(view.getContentWidth()); - setFont(Font.font(null, FontWeight.BOLD, -1)); // Apply only to the header column. - setOnMouseMoved((GridViewSkin) view.getSkin()); - setManaged(false); - } - - /** - * Invoked when this {@code GridRow} is used for showing a new image row. - * We override this method as an alternative to registering a listener to - * {@link #indexProperty()} (for reducing the number of object allocations). - * - * @param row index of the new row. - */ - @Override - public void updateIndex(final int row) { - super.updateIndex(row); - tileY = view.toTileY(row); - final Skin<?> skin = getSkin(); - if (skin != null) { - ((GridRowSkin) skin).setRowIndex(row); - } - updateItem(null, row < 0 || row >= view.getImageHeight()); - } - - /** - * Returns the sample value in the given column of this row. If the tile is not available at the time - * this method is invoked, then the tile will loaded in a background thread and the grid view will be - * refreshed when the tile become available. - * - * @param column zero-based <var>x</var> coordinate of sample to get (may differ from image coordinate). - * @return the sample value in the specified column, or {@code null} if not yet available. - */ - final String getSampleValue(final int column) { - return view.getSampleValue(tileY, getIndex(), column); - } - - /** - * Creates a new instance of the skin responsible for rendering this grid row. - * From the perspective of {@link IndexedCell}, the {@link Skin} is a black box. - * It listens and responds to changes in state of this grid row. - * - * @return the renderer of this grid row. - */ - @Override - protected Skin<GridRow> createDefaultSkin() { - return new GridRowSkin(this); - } -} diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRowSkin.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRowSkin.java deleted file mode 100644 index 32788075a6..0000000000 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRowSkin.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.sis.gui.coverage; - -import java.util.List; -import java.util.ArrayList; -import javafx.scene.Node; -import javafx.scene.text.Text; -import javafx.scene.control.skin.CellSkinBase; -import javafx.collections.ObservableList; -import javafx.geometry.Pos; - - -/** - * The renderer of {@link GridRow} instances. On construction, this object contains only one child. - * That child is an instance of {@link javafx.scene.text.Text} and is used for the row header. All - * other children will be instances of {@link GridCell} created and removed as needed during the - * layout pass. - * - * @author Martin Desruisseaux (Geomatys) - */ -final class GridRowSkin extends CellSkinBase<GridRow> { - /** - * Invoked by {@link GridRow#createDefaultSkin()}. - */ - GridRowSkin(final GridRow owner) { - super(owner); - setRowIndex(owner.getIndex()); - } - - /** - * Invoked when the index to show in the header column changed. - */ - final void setRowIndex(final int index) { - final Text header = (Text) getChildren().get(0); - header.setText(getSkinnable().view.formatHeaderValue(index, true)); - } - - /** - * Invoked during the layout pass to position the cells to be rendered by this row. - * This method also sets the content of the cell. - * - * The {@code width} argument can be a large number (for example 24000) because it includes - * the area outside the view. In order to avoid creating a large number of {@link GridCell} - * instances, this method have to find the current view port area and render only the cells - * in that area. We do not have to do that vertically because the vertical virtualization - * is done by {@link GridViewSkin} parent class. - * - * <h4>Implementation note</h4> - * I'm not sure it is a good practice to add/remove children and to modify text values here, - * but I have not identified another place yet. However, the JavaFX implementation of table - * skin seems to do the same, so I presume it is okay. - * - * @param x the <var>x</var> position of this row, usually 0. - * @param y the <var>y</var> position of this row, usually 0 (this is a relative position). - * @param width width of the row, including the area currently hidden because out of view. - * @param height height of the region where to render this row (for example 16). - */ - @Override - protected void layoutChildren(final double x, final double y, final double width, final double height) { - /* - * Do not invoke super.layoutChildren(…) since we are doing a different layout. - * The first child is a `javafx.scene.text.Text`, which we use for row header. - */ - final ObservableList<Node> children = getChildren(); - final GridRow row = getSkinnable(); - final GridViewSkin layout = (GridViewSkin) row.view.getSkin(); - /* - * Set the position of the header cell, but not its content. The content has been set by - * `setRowIndex(int)` and does not need to be recomputed even during horizontal scroll. - */ - double pos = layout.leftPosition; // Horizontal position in the virtual view. - ((Text) children.get(0)).resizeRelocate(pos, y, layout.headerWidth, height); - pos += layout.headerWidth; - /* - * Get the beginning (pos) and end (limit) of the region to render. We create only the amount - * of GridCell instances needed for rendering this region. We should not create cells for the - * whole row since it would be too many cells (can be millions). Instead, we recycle the cells - * in a list of children that we try to keep small. All children starting at index 1 shall be - * GridCell instances created in this method. - */ - final double cellWidth = layout.cellWidth; // Includes the cell spacing. - final double available = layout.cellInnerWidth; - final double limit = layout.rightPosition; // Horizontal position where to stop. - int column = layout.firstVisibleColumn; // Zero-based column index in image. - int childIndex = 0; - List<GridCell> newChildren = null; - final int count = children.size(); - while (pos < limit) { - /* - * For sample value, we need to recompute both the values and the position. Note that even if - * the cells appear at the same positions visually (with different content), they moved in the - * virtual flow if some scrolling occurred. - */ - final GridCell cell; - if (++childIndex < count) { - cell = (GridCell) children.get(childIndex); - } else { - cell = new GridCell(); - cell.setAlignment(Pos.CENTER_RIGHT); - if (newChildren == null) { - newChildren = new ArrayList<>(1 + (int) ((limit - pos) / cellWidth)); - } - newChildren.add(cell); - } - final String value = row.getSampleValue(column++); - cell.updateItem(value, value == GridView.OUT_OF_BOUNDS); // Identity comparison is okay here. - cell.resizeRelocate(pos, 0, available, height); - pos += cellWidth; - } - /* - * Add or remove fields only at the end of this method in order to fire only one change event. - * It is important to remove unused fields not only for saving memory, but also for preventing - * those fields to appear at random positions in the rendered region. - */ - if (newChildren != null) { - children.addAll(newChildren); - } else if (++childIndex < count) { - children.remove(childIndex, count); - } - } -} diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridTile.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridTile.java index d599609e26..be5cea1736 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridTile.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridTile.java @@ -23,8 +23,8 @@ import org.apache.sis.gui.internal.BackgroundThreads; /** - * A {@link Raster} for a {@link RenderedImage} tile, - * potentially fetched (loaded or computed) in a background thread. + * A single tile potentially fetched (loaded or computed) from an image in a background thread. + * The source is a {@link RenderedImage} and the cached object is {@link Raster}. * * @author Martin Desruisseaux (Geomatys) */ @@ -34,13 +34,6 @@ final class GridTile { */ final int tileX, tileY; - /** - * Hash code value computed from tile indices only. Other fields must be ignored. - * - * @see #hashCode() - */ - private final int hashCode; - /** * The tile, or {@code null} if not yet fetched. * @@ -62,30 +55,30 @@ final class GridTile { /** * Creates a new tile for the given tile coordinates. */ - GridTile(final int tileX, final int tileY, final int numXTiles) { + GridTile(final int tileX, final int tileY) { this.tileX = tileX; this.tileY = tileY; - hashCode = tileX + tileY * numXTiles; } /** - * Returns a hash code value for this tile. This hash code value must be based only on tile indices; - * the {@link #tile} and the {@link #error} must be ignored, because we will use {@link GridTile} + * Returns a hash code value for this tile. This hash code value must be based only on tile indices. + * The {@link #tile} and the {@link #error} must be ignored, because we will use {@link GridTile} * instances also as keys for locating tiles in a hash map. */ @Override public int hashCode() { - return hashCode; + return tileX ^ Integer.reverse(tileY); } /** * Compares the indices of this tile with the given object for equality. - * Only indices are compared; the raster is ignored. See {@link #hashCode()} for more information. + * Only indices are compared, the raster is ignored. + * See {@link #hashCode()} for more information. */ @Override public boolean equals(final Object other) { if (other instanceof GridTile) { - final GridTile that = (GridTile) other; + final var that = (GridTile) other; return tileX == that.tileX && tileY == that.tileY; // Intentionally no other comparisons. } @@ -147,8 +140,11 @@ final class GridTile { loading = true; final RenderedImage image = view.getImage(); BackgroundThreads.execute(new Task<Raster>() { - /** Invoked in background thread for fetching the tile. */ - @Override protected Raster call() { + /** + * Invoked in background thread for fetching the tile. + */ + @Override + protected Raster call() { return image.getTile(tileX, tileY); } @@ -157,11 +153,12 @@ final class GridTile { * the same image, it will be informed that the tile is available. Otherwise * (if the image has changed) we ignore the result. */ - @Override protected void succeeded() { + @Override + protected void succeeded() { clear(); if (view.getImage() == image) { tile = getValue(); - view.contentChanged(false); + view.updateCellValues(); } } @@ -169,7 +166,8 @@ final class GridTile { * Invoked in JavaFX thread on failure. Discards everything and sets the error message * if {@link GridView} is still showing the image for which we failed to load a tile. */ - @Override protected void failed() { + @Override + protected void failed() { clear(); if (view.getImage() == image) { error = new GridError(view, GridTile.this, getException()); @@ -182,7 +180,8 @@ final class GridTile { * Ideally we should interrupt the {@link RenderedImage#getTile(int, int)} * process, but we currently have no API for that. */ - @Override protected void cancelled() { + @Override + protected void cancelled() { clear(); } }); diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridView.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridView.java index a2670c23d8..0246522e43 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridView.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridView.java @@ -32,10 +32,12 @@ import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.StringProperty; import javafx.concurrent.Task; import javafx.scene.control.Control; +import javafx.scene.control.ScrollBar; import javafx.scene.control.Skin; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import org.apache.sis.util.ArgumentChecks; +import org.apache.sis.util.privy.Numerics; import org.apache.sis.coverage.grid.GridExtent; import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.coverage.grid.GridCoverage; @@ -43,7 +45,6 @@ import org.apache.sis.storage.DataStoreException; import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.gui.internal.BackgroundThreads; import org.apache.sis.gui.internal.LogHandler; -import org.apache.sis.gui.internal.Styles; import org.apache.sis.gui.internal.ExceptionReporter; import org.apache.sis.image.DataType; @@ -54,12 +55,10 @@ import org.apache.sis.image.DataType; * The view shows one band at a time, but the band to show can be changed (thus providing a navigation in a third * dimension). * - * <p>This class is designed for large images, with tiles loaded in a background thread only when first needed. - * This is not a general purpose grid viewer; for matrices of relatively small size (e.g. less than 100 columns), - * consider using the standard JavaFX {@link javafx.scene.control.TableView} instead.</p> + * <p>This class is designed for large images, with tiles loaded in a background thread only when first needed.</p> * * @author Martin Desruisseaux (Geomatys) - * @version 1.3 + * @version 1.5 * * @see CoverageExplorer * @@ -67,18 +66,6 @@ import org.apache.sis.image.DataType; */ @DefaultProperty("image") public class GridView extends Control { - /** - * Minimum cell width and height. Must be greater than zero, otherwise infinite loops may happen. - * - * @see #getSizeValue(DoubleProperty) - */ - static final int MIN_CELL_SIZE = 1; - - /** - * The string value for sample values that are out of image bounds. - */ - static final String OUT_OF_BOUNDS = ""; - /** * If a loading is in progress, the loading process. Otherwise {@code null}. */ @@ -96,8 +83,10 @@ public class GridView extends Control { /** * Information copied from {@link #imageProperty} for performance. + * Those values are used only for checking if a coordinate is inside image bounds. + * The maximum coordinates are exclusive. */ - private int width, height, minX, minY, numXTiles; + private int minX, minY, maxX, maxY; /** * Information copied from {@link #imageProperty} for performance. @@ -106,17 +95,16 @@ public class GridView extends Control { private int tileWidth, tileHeight; /** - * Information copied and adjusted from {@link #imageProperty} for performance. Values are adjusted for using - * zero-based indices as expected by JavaFX tables (by contrast, pixel indices in a {@link RenderedImage} may - * start at a non-zero value). + * Information copied from {@link #imageProperty} for performance. */ private int tileGridXOffset, tileGridYOffset; /** - * A cache of most recently used {@link #imageProperty} tiles. We use a very simple caching mechanism here, - * keeping the most recently used tiles up to 10 Mb of memory. We do not need more sophisticated mechanism - * since "real" caching is done by {@link org.apache.sis.image.ComputedImage}. The purpose of this cache is - * to remember that a tile is immediately available and that we do not need to start a background thread. + * A cache of most recently used {@link #imageProperty} tiles. + * We use a simple caching mechanism, keeping the most recently used tiles up to some maximal amount of memory. + * No need for something more advanced because the real cache is done by {@link org.apache.sis.image.ComputedImage}. + * The purpose of this cache is to remember that a tile is immediately available and that we do not need to start + * a background thread. */ private final GridTileCache tiles; @@ -142,7 +130,7 @@ public class GridView extends Control { * It shall be a number strictly greater than zero. * * <h4>API note</h4> - * We do not provide getter/setter for this property; use {@link DoubleProperty#set(double)} + * We do not provide getter/setter for this property, use {@link DoubleProperty#set(double)} * directly instead. We omit the "Property" suffix for making this operation more natural. */ public final DoubleProperty headerWidth; @@ -153,7 +141,7 @@ public class GridView extends Control { * It shall be a number strictly greater than zero. * * <h4>API note</h4> - * We do not provide getter/setter for this property; use {@link DoubleProperty#set(double)} + * We do not provide getter/setter for this property, use {@link DoubleProperty#set(double)} * directly instead. We omit the "Property" suffix for making this operation more natural. */ public final DoubleProperty cellWidth; @@ -163,7 +151,7 @@ public class GridView extends Control { * It shall be a number strictly greater than zero. * * <h4>API note</h4> - * We do not provide getter/setter for this property; use {@link DoubleProperty#set(double)} + * We do not provide getter/setter for this property, use {@link DoubleProperty#set(double)} * directly instead. We omit the "Property" suffix for making this operation more natural. */ public final DoubleProperty cellHeight; @@ -174,7 +162,7 @@ public class GridView extends Control { * {@linkplain #cellHeight cell height} should be sufficient. * * <h4>API note</h4> - * We do not provide getter/setter for this property; use {@link DoubleProperty#set(double)} + * We do not provide getter/setter for this property, use {@link DoubleProperty#set(double)} * directly instead. We omit the "Property" suffix for making this operation more natural. */ public final DoubleProperty cellSpacing; @@ -183,7 +171,7 @@ public class GridView extends Control { * The background color of row and column headers. * * <h4>API note</h4> - * We do not provide getter/setter for this property; use {@link ObjectProperty#set(Object)} + * We do not provide getter/setter for this property, use {@link ObjectProperty#set(Object)} * directly instead. We omit the "Property" suffix for making this operation more natural. */ public final ObjectProperty<Paint> headerBackground; @@ -204,13 +192,13 @@ public class GridView extends Control { final CellFormat cellFormat; /** - * If this grid view is associated with controls, the controls. Otherwise {@code null}. - * This is used only for notifications; a future version may use a more generic listener. + * If this grid view is associated with controls, these controls. Otherwise {@code null}. + * This is used only for notifications. A future version may use a more generic listener. * We use this specific mechanism because there is no {@code coverageProperty} in this class. * * @see GridControls#notifyDataChanged(GridCoverageResource, GridCoverage) */ - private final GridControls controls; + final GridControls controls; /** * Creates an initially empty grid view. The content can be set after @@ -231,7 +219,7 @@ public class GridView extends Control { this.controls = controls; bandProperty = new BandProperty(); imageProperty = new SimpleObjectProperty<>(this, "image"); - headerWidth = new SimpleDoubleProperty (this, "headerWidth", 60); + headerWidth = new SimpleDoubleProperty (this, "headerWidth", 80); cellWidth = new SimpleDoubleProperty (this, "cellWidth", 60); cellHeight = new SimpleDoubleProperty (this, "cellHeight", 20); cellSpacing = new SimpleDoubleProperty (this, "cellSpacing", 4); @@ -250,6 +238,7 @@ public class GridView extends Control { /** * The property for selecting the band to show. This property verifies * the validity of given band argument before to modify the value. + * The expected value is a zero-based band index. * * @see #getBand() * @see #setBand(int) @@ -269,7 +258,7 @@ public class GridView extends Control { ArgumentChecks.ensurePositive("band", band); } super.set(band); - contentChanged(false); + updateCellValues(); } /** Sets the band without performing checks, except ensuring that value is positive. */ @@ -292,7 +281,8 @@ public class GridView extends Control { /** * Sets the image to show in this table. - * This method shall be invoked from JavaFX thread and returns quickly; it does not attempt to fetch any tile. + * This method shall be invoked from the JavaFX thread. + * This method returns quickly, it does not attempt to fetch any tile. * Calls to {@link RenderedImage#getTile(int, int)} will be done in a background thread when first needed. * * @param image the image to show in this table, or {@code null} if none. @@ -307,8 +297,8 @@ public class GridView extends Control { /** * Loads image in a background thread from the given source. * This method shall be invoked from JavaFX thread and returns immediately. - * The grid content may appear unmodified after this method returns; - * the modifications will appear after an undetermined amount of time. + * The grid content may appear unmodified after this method returns. + * The modifications will appear after an undetermined amount of time. * * @param source the coverage or resource to load, or {@code null} if none. * @@ -467,24 +457,19 @@ public class GridView extends Control { * See {@link #setImage(RenderedImage)} for more description. * * @param image the new image to show. May be {@code null}. - * @throws ArithmeticException if the "tile grid x/y offset" property is too large. + * @throws ArithmeticException if the "max x/y" property is too large. */ private void onImageSpecified(final RenderedImage image) { cancelLoader(); - tiles.clear(); // Let garbage collector dispose the rasters. + tiles.clear(); // Let the garbage collector disposes the rasters. lastTile = null; - width = 0; - height = 0; + maxX = Integer.MIN_VALUE; // A way to make sure that all coordinates are considered out of bounds. + maxY = Integer.MIN_VALUE; if (image != null) { - minX = image.getMinX(); - minY = image.getMinY(); - width = image.getWidth(); - height = image.getHeight(); - numXTiles = image.getNumXTiles(); tileWidth = Math.max(1, image.getTileWidth()); tileHeight = Math.max(1, image.getTileHeight()); - tileGridXOffset = Math.subtractExact(image.getTileGridXOffset(), minX); - tileGridYOffset = Math.subtractExact(image.getTileGridYOffset(), minY); + tileGridXOffset = image.getTileGridXOffset(); + tileGridYOffset = image.getTileGridYOffset(); cellFormat.dataTypeIsInteger = false; // To be kept consistent with `cellFormat` pattern. final SampleModel sm = image.getSampleModel(); if (sm != null) { // Should never be null, but we are paranoiac. @@ -495,64 +480,71 @@ public class GridView extends Control { cellFormat.dataTypeIsInteger = DataType.isInteger(sm); } cellFormat.configure(image, getBand()); + // Set image bounds only after everything else succeeded. + minX = image.getMinX(); + maxX = Math.addExact(minX, image.getWidth()); + minY = image.getMinY(); + maxY = Math.addExact(minY, image.getHeight()); } - contentChanged(true); - } - - /** - * Invoked when the content may have changed. If {@code all} is {@code true}, then everything - * may have changed including the number of rows and columns. If {@code all} is {@code false} - * then the number of rows and columns is assumed the same. - */ - final void contentChanged(final boolean all) { final Skin<?> skin = getSkin(); // May be null if the view is not yet shown. if (skin instanceof GridViewSkin) { // Could be a user instance (not recommended). - ((GridViewSkin) skin).contentChanged(all); + ((GridViewSkin) skin).clear(); } + requestLayout(); } /** - * Returns the width that this view would have if it was fully shown (without horizontal scroll bar). - * This value depends on the number of columns in the image and the size of each cell. - * This method does not take in account the space occupied by the vertical scroll bar. - */ - final double getContentWidth() { - /* - * Add one more column for avoiding offsets caused by the rounding of scroll bar position to - * integer multiple of column size. The SCROLLBAR_WIDTH minimal value used below is arbitrary; - * we take a value close to the vertical scrollbar width as a safety. - */ - final double w = getSizeValue(cellWidth); - return width * w + getSizeValue(headerWidth) + Math.max(w, Styles.SCROLLBAR_WIDTH); - } - - /** - * Returns the value of the given property as a real number not smaller than {@value #MIN_CELL_SIZE}. - * We use this method instead of {@link Math#max(double, double)} because we want {@link Double#NaN} - * values to be replaced by {@value #MIN_CELL_SIZE}. + * Rewrites the cell values. This method can be invoked when the band to show has changed, + * or when a change is detected in a writable image. This method assumes that the image size + * has not changed. */ - static double getSizeValue(final DoubleProperty property) { - final double value = property.get(); - return (value >= MIN_CELL_SIZE) ? value : MIN_CELL_SIZE; + final void updateCellValues() { + final Skin<?> skin = getSkin(); // May be null if the view is not yet shown. + if (skin instanceof GridViewSkin) { // Could be a user instance (not recommended). + ((GridViewSkin) skin).updateCellValues(); + } } /** - * Returns the number of rows in the image. This is also the number of rows in the - * {@link GridViewSkin} virtual flow, which is using a vertical primary direction. + * Configures the given scroll bar. * - * @see javafx.scene.control.skin.VirtualContainerBase#getItemCount() + * @param bar the scroll bar to configure. + * @param numCells number of cells created by the caller (one more than the number of visible cells). + * @param vertical {@code true} if the scroll bar is vertical, or {@code false} if horizontal. */ - final int getImageHeight() { - return height; + final void scaleScrollBar(final ScrollBar bar, int numCells, final boolean vertical) { + int min, max; + if (vertical) { + min = minY; + max = maxY; + } else { + min = minX; + max = maxX; + } + if (max > min) { + numCells = Math.max(1, Math.min(numCells - 2, max)); + max -= numCells; + bar.setMin(min); + bar.setMax(max); + bar.setVisibleAmount(numCells); + double value = bar.getValue(); + if (value < min) { + value = min; + } else if (value > max) { + value = max; + } else { + return; + } + bar.setValue(value); + } } /** * Returns the bounds of a single tile in the image. This method is invoked only * if an error occurred during {@link RenderedImage#getTile(int, int)} invocation. - * The returned bounds are zero-based (may not be the bounds in image coordinates). * * <h4>Design note</h4> - * We use AWT rectangle instead of JavaFX rectangle + * We use <abbr>AWT</abbr> rectangle instead of JavaFX rectangle * because generally we use AWT for everything related to {@link RenderedImage}. * * @param tileX <var>x</var> coordinates of the tile for which to get the bounds. @@ -560,51 +552,39 @@ public class GridView extends Control { * @return the zero-based bounds of the specified tile in the image. */ final Rectangle getTileBounds(final int tileX, final int tileY) { - return new Rectangle(tileX * tileWidth + tileGridXOffset, - tileY * tileHeight + tileGridYOffset, + return new Rectangle(Numerics.clamp(tileGridXOffset + Math.multiplyFull(tileX, tileWidth)), + Numerics.clamp(tileGridYOffset + Math.multiplyFull(tileY, tileHeight)), tileWidth, tileHeight); } /** - * Converts a grid row index to tile index. Note that those {@link RenderedImage} - * tile coordinates do not necessarily start at 0; negative values may be valid. + * Formats the sample value at the image coordinates. If the tile is not available at the time + * that this method is invoked, then the tile will be loaded in a background thread and the grid + * view will be refreshed when the tile become available. * - * @see GridRow#tileY - */ - final int toTileY(final int row) { - return Math.floorDiv(Math.subtractExact(row, tileGridYOffset), tileHeight); - } - - /** - * Returns the sample value in the given column of the given row. If the tile is not available at the - * time this method is invoked, then the tile will loaded in a background thread and the grid view will - * be refreshed when the tile become available. + * @param x image <var>x</var> coordinate of the sample value to get. + * @param y image <var>y</var> coordinate of the sample value to get. + * @return the sample value at the specified coordinates, or {@code null} if not available. * - * <p>The {@code tileY} parameter is computed by {@link #toTileY(int)} and stored in {@link GridRow}.</p> + * @see #formatCoordinateValue(long) * - * @param tileY arbitrary-based <var>y</var> coordinate of the tile. - * @param row zero-based <var>y</var> coordinate of sample to get (may differ from image coordinate Y). - * @param column zero-based <var>x</var> coordinate of sample to get (may differ from image coordinate X). - * @return the sample value in the specified column, or {@code null} if unknown (because the loading process - * is still under progress), or the empty string ({@code ""}) if out of bounds. - * @throws ArithmeticException if an index is too large for the 32 bits integer capacity. - * - * @see GridRow#getSampleValue(int) + * @since 1.5 */ - final String getSampleValue(final int tileY, final int row, final int column) { - if (row < 0 || row >= height || column < 0 || column >= width) { - return OUT_OF_BOUNDS; + public final String formatSampleValue(final long x, final long y) { + if (x < minX || x >= maxX || y < minY || y >= maxY) { + return null; } /* * Fetch the tile where is located the (x,y) image coordinate of the pixel to get. * If that tile has never been requested before, or has been discarded by the cache, - * start a background thread for fetching the tile and return null immediately; this + * start a background thread for fetching the tile and return null immediately. This * method will be invoked again with the same coordinates after the tile become ready. */ - final int tileX = Math.floorDiv(Math.subtractExact(column, tileGridXOffset), tileWidth); + final int tileX = Math.toIntExact(Math.floorDiv(x - tileGridXOffset, tileWidth)); + final int tileY = Math.toIntExact(Math.floorDiv(y - tileGridYOffset, tileHeight)); GridTile cache = lastTile; if (cache == null || cache.tileX != tileX || cache.tileY != tileY) { - final GridTile key = new GridTile(tileX, tileY, numXTiles); + final var key = new GridTile(tileX, tileY); cache = tiles.putIfAbsent(key, key); if (cache == null) cache = key; lastTile = cache; @@ -614,28 +594,21 @@ public class GridView extends Control { cache.load(this); return null; } - /* - * At this point we have the tile. Get the desired number and format its string representation. - * As a slight optimization, we reuse the previous string representation if the number is the same. - * It may happen in particular with fill values. - */ - return cellFormat.format(tile, - Math.addExact(column, minX), - Math.addExact(row, minY), - getBand()); + // The casts are sure to be valid because of the range check at the beginning of this method. + return cellFormat.format(tile, (int) x, (int) y, getBand()); } /** - * Formats a row index or column index. + * Formats a <var>x</var> or <var>y</var> pixel coordinate values. + * They are the values to write in the header row or header column. + * + * @param index the pixel coordinate to format. + * @return string representation of the given pixel coordinate. * - * @param index the zero-based row or column index to format. - * @param vertical {@code true} if formatting row index, or {@code false} if formatting column index. + * @since 1.5 */ - final String formatHeaderValue(final int index, final boolean vertical) { - if (index >= 0 && index < (vertical ? height : width)) { - return cellFormat.format(headerFormat, index + (long) (vertical ? minY : minX)); - } - return OUT_OF_BOUNDS; + public final String formatCoordinateValue(final long index) { + return cellFormat.format(headerFormat, index); } /** @@ -651,18 +624,6 @@ public class GridView extends Control { return cellFormat.hasPattern() ? Optional.of(cellFormat) : Optional.empty(); } - /** - * Converts and formats the given cell coordinates. An offset is added to the coordinate values for converting - * cell indices to pixel indices. Those two kind of indices often have the same values, but may differ if the - * {@link RenderedImage} uses a coordinate system where coordinates of the upper-left corner is not (0,0). - * Then the pixel coordinates are converted to "real world" coordinates and formatted. - */ - final void formatCoordinates(final int x, final int y) { - if (controls != null) { - controls.status.setLocalCoordinates(minX + x, minY + y); - } - } - /** * Hides coordinates in the status bar. */ @@ -676,7 +637,7 @@ public class GridView extends Control { * Creates a new instance of the skin responsible for rendering this grid view. * From the perspective of this {@link Control}, the {@link Skin} is a black box. * It listens and responds to changes in state of this grid view. This method is - * called if no skin is provided via CSS or {@link #setSkin(Skin)}. + * called if no skin is provided via <abbr>CSS</abbr> or {@link #setSkin(Skin)}. * * @return the renderer of this grid view. */ diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridViewSkin.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridViewSkin.java index 9ba58d8854..02ae89b1a1 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridViewSkin.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridViewSkin.java @@ -16,120 +16,116 @@ */ package org.apache.sis.gui.coverage; +import java.util.List; +import java.util.Arrays; import java.awt.image.RenderedImage; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; -import javafx.collections.ObservableList; -import javafx.geometry.HPos; -import javafx.geometry.VPos; +import javafx.geometry.Orientation; import javafx.scene.Node; import javafx.scene.Cursor; import javafx.scene.control.ScrollBar; -import javafx.scene.control.skin.VirtualFlow; -import javafx.scene.control.skin.VirtualContainerBase; -import javafx.scene.layout.HBox; +import javafx.scene.control.SkinBase; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; -import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.input.KeyEvent; import javafx.event.EventHandler; import javafx.event.EventType; +import javafx.geometry.Bounds; import org.apache.sis.gui.internal.MouseDrags; import org.apache.sis.gui.internal.Styles; +import org.apache.sis.util.privy.Numerics; /** - * The {@link GridView} renderer as a virtualized and scrollable content. - * The primary direction of virtualization is vertical (rows will stack vertically on top of each other). - * - * <p>Relationships:</p> - * <ul> - * <li>This is created by {@link GridView#createDefaultSkin()}.</li> - * <li>The {@link GridView} which own this skin is given by {@link #getSkinnable()}.</li> - * <li>This {@code GridViewSkin} contains an arbitrary number of {@link GridRow} children. - * It should be limited to the number of children that are visible at the same time, - * not the total number of rows in the image.</li> - * </ul> + * The {@link GridView} renderer as a scrollable content. + * This is created by {@link GridView#createDefaultSkin()}. + * The {@link GridView} which owns this skin is given by {@link #getSkinnable()}. * * @author Martin Desruisseaux (Geomatys) */ -final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> implements EventHandler<MouseEvent> { +final class GridViewSkin extends SkinBase<GridView> implements EventHandler<MouseEvent> { /** - * The cells that we put in the header row on the top of the view. The children list is initially empty; - * new elements are added or removed when first needed and when the view size changed. + * Margin to add to the header row or header column. */ - private final HBox headerRow; + private static final int HEADER_MARGIN = 9; /** - * Background of the header row (top side) and header column (left side) of the view. + * Minimum cell width and height. Must be greater than zero, otherwise infinite loops may happen. + * + * @see #toValidCellSize(double) */ - private final Rectangle topBackground, leftBackground; + private static final int MIN_CELL_SIZE = 4; /** - * Zero-based index of the first column visible in the view, ignoring the header column. - * This is equal to the {@link RenderedImage} <var>x</var> index if the image coordinates - * also start at zero (i.e. {@link RenderedImage#getMinX()} = 0). + * Minimal width and height of the error box for showing it. + */ + private static final int MIN_ERROR_BOX_SIZE = 140; + + /** + * The cells that we put in the header row on the top of the view. + * The length of this array is the number of columns that are visible. + * This array is recreated when the view's width changed. * - * <p>This field is written by {@link #layoutChildren(double, double, double, double)}. - * All other accesses (especially from outside of this class) should be read-only.</p> + * @see #topBackground */ - int firstVisibleColumn; + private Text[] headerRow; /** - * Horizontal position in the virtual flow where to start writing the text of the header column. - * This value changes during horizontal scrolls, even if the cells continue to start at the same - * visual position on the screen. The position of the column showing {@link #firstVisibleColumn} - * sample values is {@code leftPosition} + {@link #headerWidth}, and that position is incremented - * by {@link #cellWidth} for all other columns. + * The cells that we put in the header column on the left of the view. + * The length of this array is the number of rows that are visible. + * This array is recreated when the view's height changed. * - * <p>This field is written by {@link #layoutChildren(double, double, double, double)}. - * All other accesses (especially from outside of this class) should be read-only.</p> + * @see #leftBackground */ - double leftPosition; + private Text[] headerColumn; /** - * Horizontal position where to stop rendering the cells. - * This is {@link #leftPosition} + the view width. + * All grid cells (row major) containing the pixel values of the selected band. + * The length of this array shall be {@code headerRow.length * headerColumn.length}. + * This array is recreated when the view's size changed. * - * <p>This field is written by {@link #layoutChildren(double, double, double, double)}. - * All other accesses (especially from outside of this class) should be read-only.</p> + * @see #valuesRegionX() + * @see #valuesRegionY() */ - double rightPosition; + private Text[] valueCells; /** - * Width of the header column ({@code headerWidth}) and of all other columns ({@code cellWidth}). - * Must be greater than zero, otherwise infinite loop may happen. + * Background of the header row (top side) of the view. The bottom coordinate of this + * rectangle is the <var>y</var> coordinate where to start drawing the value cells. * - * <p>This field is written by {@link #layoutChildren(double, double, double, double)}. - * All other accesses (especially from outside of this class) should be read-only.</p> + * @see #headerRow + * @see #valuesRegionY() */ - double headerWidth, cellWidth; + private final Rectangle topBackground; /** - * Width of the region where to write the text in a cell. Should be equal or slightly smaller - * than {@link #cellWidth}. We use a smaller width for leaving a small margin between cells. + * Background of the header header column (left side) of the view. The right coordinate of + * this rectangle is the <var>x</var> coordinate where to start drawing the value cells. * - * <p>This field is written by {@link #layoutChildren(double, double, double, double)}. - * All other accesses (especially from outside of this class) should be read-only.</p> + * @see #headerColumn + * @see #valuesRegionX() */ - double cellInnerWidth; + private final Rectangle leftBackground; /** - * Whether a new image has been set, in which case we should recompute everything - * including the labels in header row. + * Width of all columns other than the header column. + * This is the {@link GridView#cellWidth} value forced to at least {@value #MIN_CELL_SIZE}. */ - private boolean layoutAll; + private double cellWidth; /** - * Whether the grid view contains at least one tile that we failed to fetch. + * Height of all cells other than the header row. + * This is the {@link GridView#cellHeight} value forced to at least {@value #MIN_CELL_SIZE}. */ - private boolean hasErrors; + private double cellHeight; /** - * A rectangle around selected the cell in the content area or in the row/column header. + * A rectangle behind the selected cell in the content area or in the row/column header. + * Used to highlight which cells is below the mouse cursor. */ private final Rectangle selection, selectedRow, selectedColumn; @@ -142,101 +138,147 @@ final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> impleme /** * Cursor position at the time of previous pan event. + * The coordinate units are related to {@link RenderedImage} coordinates. * This is used for computing the translation to apply during drag events. * * @see #onDrag(MouseEvent) */ private double xPanPrevious, yPanPrevious; + /** + * Horizontal and vertical scroll bars. Units are {@link RenderedImage} pixel coordinates. + * The minimum value is the minimum image pixel coordinate, which is not necessarily zero. + */ + private final ScrollBar xScrollBar, yScrollBar; + + /** + * Range if indexes of the children which is an instance of {@link GridError}. + */ + private int indexOfFirstError, indexAfterLastError; + + /** + * The clip to apply on the view. + */ + private final Rectangle clip; + /** * Creates a new skin for the specified view. */ GridViewSkin(final GridView view) { super(view); - headerRow = new HBox(); + headerRow = new Text[0]; + headerColumn = headerRow; + valueCells = headerRow; /* * Main content where sample values will be shown. */ - final VirtualFlow<GridRow> flow = getVirtualFlow(); - flow.setCellFactory(GridRow::new); - flow.setFocusTraversable(true); - flow.setFixedCellSize(GridView.getSizeValue(view.cellHeight)); - view.cellHeight .addListener((p,o,n) -> cellHeightChanged(n)); - view.cellWidth .addListener((p,o,n) -> cellWidthChanged(n, true)); - view.headerWidth.addListener((p,o,n) -> cellWidthChanged(n, false)); + cellWidth = toValidCellSize(view.cellWidth.get()); + cellHeight = toValidCellSize(view.cellHeight.get()); /* * Rectangles for filling the background of the cells in the header row and header column. - * Those rectangles will be resized and relocated by the `layout(…)` method. + * Those rectangles will be resized and relocated by the `layoutChildren(…)` method. */ - topBackground = new Rectangle(); - leftBackground = new Rectangle(); - leftBackground.fillProperty().bind(view.headerBackground); - topBackground .fillProperty().bind(view.headerBackground); + final double valuesRegionX = toValidCellSize(view.headerWidth.get()); + final double valuesRegionY = cellHeight + HEADER_MARGIN; // No independent property yet. + leftBackground = new Rectangle(valuesRegionX, 0); + topBackground = new Rectangle(0, valuesRegionY); /* * Rectangle around the selected cell (for example the cell below mouse position). - * Become visible only when the mouse enter in the widget area. The rectangles are - * declared unmanaged for avoiding relayout of the whole widget every time that a - * rectangle position changed. + * They become visible only when the mouse enter in the widget area. */ - selection = new Rectangle(); - selectedRow = new Rectangle(); - selectedColumn = new Rectangle(); + selection = new Rectangle(valuesRegionX, valuesRegionY, cellWidth, cellHeight); + selectedRow = new Rectangle(0, valuesRegionY, valuesRegionX, cellHeight); + selectedColumn = new Rectangle(valuesRegionX, 0, cellWidth, valuesRegionY); selection .setFill(Styles.SELECTION_BACKGROUND); selectedRow .setFill(Color.SILVER); selectedColumn.setFill(Color.SILVER); selection .setVisible(false); selectedRow .setVisible(false); selectedColumn.setVisible(false); - selection .setManaged(false); - selectedRow .setManaged(false); - selectedColumn.setManaged(false); - flow.setOnMouseExited((e) -> hideSelection()); - /* - * The list of children is initially empty. We need to add the virtual flow - * (together with headers, selection, etc.), otherwise nothing will appear. - */ - getChildren().addAll(topBackground, leftBackground, selectedColumn, - selectedRow, headerRow, selection, flow); /* - * Keyboard and drag events for moving the viewed bounds. + * Scroll bars. */ + xScrollBar = new ScrollBar(); + yScrollBar = new ScrollBar(); + yScrollBar.setOrientation(Orientation.VERTICAL); + view.setClip(clip = new Rectangle()); + } + + /** + * Registers the listeners. This is done outside the constructor because this + * method creates references from {@link GridView} to this {@code GridViewSkin}. + */ + @Override + public void install() { + super.install(); + xScrollBar.valueProperty().addListener((p,o,n) -> positionChanged(o, n, false)); + yScrollBar.valueProperty().addListener((p,o,n) -> positionChanged(o, n, true)); + + final GridView view = getSkinnable(); + topBackground .fillProperty().bind(view.headerBackground); + leftBackground.fillProperty().bind(view.headerBackground); + view.headerWidth.addListener((p,o,n) -> cellSizeChanged(o, n, HEADER_PROPERTY)); + view.cellWidth .addListener((p,o,n) -> cellSizeChanged(o, n, WIDTH_PROPERTY)); + view.cellHeight .addListener((p,o,n) -> cellSizeChanged(o, n, HEIGHT_PROPERTY)); view.addEventHandler(KeyEvent.KEY_PRESSED, this::onKeyTyped); + view.setOnMouseExited((e) -> hideSelection()); + view.setOnMouseMoved(this); MouseDrags.setHandlers(view, this::onDrag); } + /** + * Removes the listeners installed by {@link #install()}. + * + * <h4>Limitations</h4> + * We don't have an easy way to remove the listeners on properties. + * But it should not be an issue, because this method is defined mostly as + * a matter of principle since we don't expect users to remove this skin. + */ + @Override + public void dispose() { + final GridView view = getSkinnable(); + topBackground .fillProperty().unbind(); + leftBackground.fillProperty().unbind(); + view.setOnMouseExited(null); + view.setOnMouseMoved(null); + MouseDrags.setHandlers(view, null); + super.dispose(); + } + /** * Invoked when the mouse is moving over the cells. This method computes cell indices * and draws the selection rectangle around that cell. Then, listeners are notified. * - * <p>This listener is registered for each {@link GridRow} instances. - * It is not designed for other kinds of event source.</p> - * * @see #onDrag(MouseEvent) */ @Override public final void handle(final MouseEvent event) { - double x = event.getX() - (leftPosition + headerWidth); - boolean visible = (x >= 0); + final double valuesRegionX = valuesRegionX(); + double x = (event.getX() - valuesRegionX) / cellWidth; + boolean visible = (x >= 0 && x < headerRow.length); if (visible) { - final double column = Math.floor(x / cellWidth); - visible = (column >= 0); + final double valuesRegionY = valuesRegionY(); + double y = (event.getY() - valuesRegionY) / cellHeight; + visible = (y >= 0 && y < headerColumn.length); if (visible) { - final GridRow row = (GridRow) event.getSource(); - double y = row.getLayoutY(); - visible = y < ((Flow) getVirtualFlow()).getVisibleHeight(); - if (visible) { - x = column * cellWidth + leftBackground.getWidth() + row.getLayoutX(); - y += topBackground.getHeight(); - selection.setX(x); // NOT equivalent to `relocate(x,y)`. - selection.setY(y); - selectedRow.setY(y); - selectedColumn.setX(x); - getSkinnable().formatCoordinates(firstVisibleColumn + (int) column, row.getIndex()); + final double xminOfValues = xScrollBar.getValue(); + final double yminOfValues = yScrollBar.getValue(); + x = Math.floor(x + xminOfValues); + y = Math.floor(y + yminOfValues); + final double xpos = valuesRegionX + (x - xminOfValues) * cellWidth; + final double ypos = valuesRegionY + (y - yminOfValues) * cellHeight; + selection.setX(xpos); // NOT equivalent to `relocate(x,y)`. + selection.setY(ypos); + selectedRow.setY(ypos); + selectedColumn.setX(xpos); + final GridControls controls = getSkinnable().controls; + if (controls != null) { + controls.status.setLocalCoordinates(x, y); } } } - selection .setVisible(visible); - selectedRow .setVisible(visible); + selection.setVisible(visible); + selectedRow.setVisible(visible); selectedColumn.setVisible(visible); if (!visible) { getSkinnable().hideCoordinates(); @@ -245,36 +287,35 @@ final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> impleme /** * Invoked when the user presses the button, drags the grid and releases the button. - * The position of the selection rectangles become invalid has a result of the drag. - * Instead of bothering to adjust it, we hide it. It may also be less confusing for - * the user, because it shows that we are not exploring values of different cells. * * @see #handle(MouseEvent) */ private void onDrag(final MouseEvent event) { if (event.getButton() == MouseButton.PRIMARY) { - final double x = event.getX() - leftBackground.getWidth(); - final double y = event.getY() - topBackground.getHeight(); - final Flow flow = (Flow) getVirtualFlow(); - if (x >= 0 && y >= 0 && y < flow.getVisibleHeight()) { - final GridView view = getSkinnable(); - final EventType<? extends MouseEvent> type = event.getEventType(); - if (type == MouseEvent.MOUSE_PRESSED) { - view.setCursor(Cursor.CLOSED_HAND); - view.requestFocus(); + final double x = (event.getX() - valuesRegionX()) / cellWidth; + if (x >= 0 && x < headerRow.length) { + final double y = (event.getY() - valuesRegionY()) / cellHeight; + if (y >= 0 && y < headerColumn.length) { + final GridView view = getSkinnable(); + final EventType<? extends MouseEvent> type = event.getEventType(); + if (type == MouseEvent.MOUSE_PRESSED) { + view.setCursor(Cursor.CLOSED_HAND); + view.requestFocus(); + isDragging = true; + } else if (isDragging) { + if (type == MouseEvent.MOUSE_RELEASED) { + view.setCursor(Cursor.DEFAULT); + isDragging = false; + } + shift(xScrollBar, xPanPrevious - x, false); + shift(yScrollBar, yPanPrevious - y, false); + } else { + return; + } xPanPrevious = x; yPanPrevious = y; - isDragging = true; - hideSelection(); - } else if (isDragging) { - if (type == MouseEvent.MOUSE_RELEASED) { - view.setCursor(Cursor.DEFAULT); - isDragging = false; - } - xPanPrevious -= flow.scrollHorizontal(xPanPrevious - x); - yPanPrevious -= flow.scrollPixels(yPanPrevious - y); + event.consume(); } - event.consume(); } } } @@ -285,409 +326,406 @@ final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> impleme * scrolling instead of the selection rectangle moving. */ private void onKeyTyped(final KeyEvent event) { - double tx=0, ty=0; + int tx=0, ty=0; switch (event.getCode()) { case RIGHT: case KP_RIGHT: tx = 1; break; case LEFT: case KP_LEFT: tx = -1; break; case DOWN: case KP_DOWN: ty = +1; break; case UP: case KP_UP: ty = -1; break; + case PAGE_DOWN: ty = Math.max(headerColumn.length - 3, 1); break; + case PAGE_UP: ty = Math.min(3 - headerColumn.length, -1); break; default: return; } - if (event.isShiftDown()) { - tx *= 10; - ty *= 10; - } - final Flow flow = (Flow) getVirtualFlow(); - flow.scrollPixels(flow.getFixedCellSize() * ty); - flow.scrollHorizontal(cellWidth * tx); - hideSelection(); + if (tx != 0) shift(xScrollBar, tx, true); + if (ty != 0) shift(yScrollBar, ty, true); event.consume(); } /** - * Hides the selection when the mouse moved outside the grid view area, - * or when a drag or scrolling action is performed. + * Increments or decrements the value of the given scroll bar by the given amount. + * + * @param bar the scroll bar for which to modify the position. + * @param shift the increment to add, in units of {@link RenderedImage} coordinates. */ - private void hideSelection() { - selection .setVisible(false); - selectedRow .setVisible(false); - selectedColumn.setVisible(false); - getSkinnable().hideCoordinates(); + private static void shift(final ScrollBar bar, final double shift, final boolean snap) { + double value = bar.getValue() + shift; + if (snap) { + value = Math.rint(value); + } + bar.setValue(Math.max(bar.getMin(), Math.min(bar.getMax(), value))); } /** - * Invoked when the value of {@link GridView#cellHeight} property changed. - * This method copies the new value into {@link VirtualFlow#fixedCellSizeProperty()} after bounds check. + * Invoked when the scroll bar changed its position. This is not only in reaction to direct interaction + * with the scroll bar, but may also be in response to a key pressed on the keyboard or a drag event. */ - private void cellHeightChanged(Number newValue) { - final Flow flow = (Flow) getVirtualFlow(); - double value = newValue.doubleValue(); - if (!(value >= GridView.MIN_CELL_SIZE)) { // Use ! for catching NaN values. - value = GridView.MIN_CELL_SIZE; + private void positionChanged(final Number oldValue, final Number newValue, final boolean vertical) { + final double shift = newValue.doubleValue() - oldValue.doubleValue(); + if (vertical) { + final double ypos = selection.getY() - shift * cellHeight; + selection.setY(ypos); + selectedRow.setY(ypos); + } else { + final double xpos = selection.getX() - shift * cellWidth; + selection.setX(xpos); + selectedColumn.setX(xpos); } - flow.setFixedCellSize(value); - selection.setVisible(false); - selection.setHeight(value); - contentChanged(false); + updateCellValues(); + } + + /** + * Identification of which size property has changed. + * Used as argument in {@link #cellSizeChanged(Number, int)}. + */ + private static final int WIDTH_PROPERTY = 0, HEIGHT_PROPERTY = 1, HEADER_PROPERTY = 2; + + /** + * Returns the given value inside the range expected by this class for cell values. + * We use this method instead of {@link Math#max(double, double)} because we want + * {@link Double#NaN} values to be replaced by {@value #MIN_CELL_SIZE}. + */ + private static double toValidCellSize(final double value) { + return (value >= MIN_CELL_SIZE) ? value : MIN_CELL_SIZE; } /** - * Invoked when the cell width or header width changed. - * This method notifies all children about the new width. + * Invoked when a cell width or cell height changed. * - * @param cell {@code true} if modifying the width of cells, or - * {@code false} if modifying the width of headers. + * @param oldValue the old cell width or cell height. + * @param newValue the new cell width or cell height. + * @param property one of the {@code *_PROPERTY} constants. */ - private void cellWidthChanged(Number newValue, final boolean cell) { - final GridView view = getSkinnable(); - final double width = view.getContentWidth(); - for (final Node child : getChildren()) { - if (child instanceof GridRow) { // The first instances are not a GridRow. - ((GridRow) child).setPrefWidth(width); + private void cellSizeChanged(final Number oldValue, final Number newValue, final int property) { + double value = toValidCellSize(newValue.doubleValue()); + switch (property) { + case HEADER_PROPERTY: { + leftBackground.setWidth(value); + selectedRow.setWidth(value); + selectedColumn.setX(value); + break; + } + case WIDTH_PROPERTY: { + cellWidth = value; + selection.setWidth(value); + selectedColumn.setWidth(value); + if (value > oldValue.doubleValue()) { + // Maybe there is enough space for the full pattern now. + getSkinnable().cellFormat.restorePattern(); + } + break; + } + case HEIGHT_PROPERTY: { + cellHeight = value; + selection.setHeight(value); + selectedRow.setHeight(value); + value += HEADER_MARGIN; // No independent property yet. + selectedColumn.setHeight(value); + topBackground.setWidth(value); + break; } } - if (cell) { - selection.setVisible(false); - selection.setWidth(newValue.doubleValue()); - } - layoutAll = true; - contentChanged(false); + hideSelection(); + getSkinnable().requestLayout(); + } + + /** + * Hides the selection when the mouse moved outside the grid view area, + * or when a drag or scrolling action is performed. + */ + private void hideSelection() { + selection .setVisible(false); + selectedRow .setVisible(false); + selectedColumn.setVisible(false); + getSkinnable().hideCoordinates(); } /** - * Invoked when an error occurred while fetching a tile. The given {@link GridError} node is added as last - * child (i.e. will be drawn on top of everything else). That child will be removed if a new image is set. + * Invoked when an error occurred while fetching a tile. The given {@link GridError} + * node will be added after the value cells, in order to be drawn on top of them. + * That child will be removed if a new image is set. */ final void errorOccurred(final GridError error) { - hasErrors = true; - getChildren().add(error); + getChildren().add(indexAfterLastError, error); + indexAfterLastError++; // Increment only after success. } /** - * Removes the given error. This method is invoked when the user wants to try again to fetch a tile. - * Callers is responsible for invoking {@link GridTile#clear()}. + * Returns all {@link GridError} instances. This is a view over the children of this skin. */ - final void removeError(final GridError error) { - final ObservableList<Node> children = getChildren(); - children.remove(error); - // The list should never be empty, so IndexOutOfBoundsException here would be a bug. - hasErrors = children.get(children.size() - 1) instanceof GridError; - contentChanged(false); + private List<Node> errors() { + return getChildren().subList(indexOfFirstError, indexAfterLastError); } /** - * Invoked when the content may have changed. If {@code all} is {@code true}, then everything - * may have changed including the number of rows and columns. If {@code all} is {@code false} - * then the number of rows and columns is assumed the same. - * - * <p>This method is invoked by {@link GridView} when the image has changed ({@code all=true}), - * or the band in the image to show has changed ({@code all=false}).</p> - * - * @see GridView#contentChanged(boolean) - */ - final void contentChanged(final boolean all) { - if (all) { - updateItemCount(); - getChildren().removeIf((node) -> (node instanceof GridError)); - layoutAll = true; - hasErrors = false; + * Removes the given error. This method is invoked when the user wants to try again to fetch a tile. + * Callers is responsible for invoking {@link GridTile#clear()}. + */ + final void removeError(final GridError error) { + if (errors().remove(error)) { + indexAfterLastError--; } - /* - * Following call may be redundant with `updateItemCount()` except if the number of - * rows did not changed, in which case `updateItemCount()` may have sent no event. - */ - ((Flow) getVirtualFlow()).changed(null, null, null); } /** - * Creates the virtual flow used by this {@link GridViewSkin}. The virtual flow - * created by this method registers a listener for horizontal scroll bar events. + * Removes all {@link GridError} instances. */ - @Override - protected VirtualFlow<GridRow> createVirtualFlow() { - return new Flow(getSkinnable()); + final void clear() { + errors().clear(); } /** - * The virtual flow used by {@link GridViewSkin}. We define that class - * mostly for getting access to the protected {@link #getHbar()} method. - * There are two main properties that we want: + * Resizes the given array of cells. If the array become longer, new labels are created. + * This is an helper method for {@link #layoutChildren(double, double, double, double)}. * - * <ul> - * <li>{@link #getHorizontalPosition()} for the position of the horizontal scroll bar.</li> - * <li>{@link #getWidth()} for the width of the visible region. - * </ul> - * - * Those two properties are used for creating the minimal amount - * of {@link GridCell}s needed for rendering the {@link GridRow}. + * @param cells the array to resize. + * @param count the desired number of elements. + * @param header whether the cells are for a header row or column. + * @return the resized array. */ - private static final class Flow extends VirtualFlow<GridRow> implements ChangeListener<Number> { - /** - * Creates a new flow for the given view. This method registers listeners - * on the properties that may require a redrawn of the full view port. - */ - Flow(final GridView view) { - setPannable(false); // We will use our own pan listeners. - getHbar().valueProperty().addListener(this); - view.bandProperty.addListener(this); - view.cellSpacing .addListener(this); - // Other listeners are registered by enclosing class. - } - - /** - * The position of the horizontal scroll bar. This is a value between 0 and - * the width that the {@link GridView} would have if we were showing it fully. - */ - final double getHorizontalPosition() { - return getHbar().getValue(); - } - - /** - * Attempts to scroll horizontally the view by the given number of pixels. - * - * @param delta the number of pixels to scroll. - * @return the number of pixels actually moved. - */ - final double scrollHorizontal(final double delta) { - final ScrollBar bar = getHbar(); - final double previous = bar.getValue(); - final double value = Math.max(bar.getMin(), Math.min(bar.getMax(), previous + delta)); - bar.setValue(value); - return value - previous; - } - - /** - * Returns the height of the view area, not counting the horizontal scroll bar. - * This height does not include the row header neither, because it is managed by - * a separated node ({@link #headerRow}). - */ - final double getVisibleHeight() { - double height = getHeight(); - final ScrollBar bar = getHbar(); - if (bar.isVisible()) { - height -= bar.getHeight(); + private static Text[] resize(Text[] cells, final int count, final boolean header) { + int i = cells.length; + if (i != count) { + cells = Arrays.copyOf(cells, count); + if (count > i) { + final Font font = header ? Font.font(null, FontWeight.BOLD, -1) : null; + do { + final var cell = new Text(); + if (header) { + cell.setFont(font); + } + cells[i] = cell; + } while (++i < count); } - return height; - } - - /** - * Invoked when the content to show changed because of a change in a property. - * The most important event is a change in the position of horizontal scroll bar, - * which is handled as a change of content because we will need to change values - * shown by the cells (because we reuse a small number of cells in visible region). - * But this method is also invoked for real changes of content like changes in the - * index of the band to show, provided that the number of rows and columns is the same. - * - * @param property the property that changed (ignored). - * @param oldValue the old value (ignored). - * @param newValue the new value (ignored). - */ - @Override - public void changed(ObservableValue<? extends Number> property, Number oldValue, Number newValue) { - // Inform VirtualFlow that a layout pass should be done, but no GridRows have been added or removed. - reconfigureCells(); } + return cells; } /** - * Invoked when it is possible that the item count has changed. JavaFX may invoke this method - * when scrolling has occurred, the control has resized, <i>etc.</i>, but for {@link GridView} - * the count will change only if a new {@link RenderedImage} has been specified. + * Returns the leftmost coordinate where value cells are rendered. This is in units of the + * coordinates given to the {@link #layoutChildren(double, double, double, double)} method, + * with {@code xmin} assumed to be zero. */ - @Override - protected void updateItemCount() { - /* - * VirtualFlow.setCellCount(int) indicates the number of cells that should be in the flow. - * When the cell count changes, VirtualFlow responds by updating the visuals. If the items - * backing the cells change but the count has not changed, then reconfigureCells() should - * be invoked instead. This is done by the `Flow` inner class above. - */ - getVirtualFlow().setCellCount(getItemCount()); // Fires event only if count changed. + private double valuesRegionX() { + return leftBackground.getWidth(); } /** - * Returns the total number of image rows, including those that are currently hidden because - * they are out of view. The returned value is (indirectly) {@link RenderedImage#getHeight()}. + * Returns the topmost coordinate where value cells are rendered. This is in units of the + * coordinates given to the {@link #layoutChildren(double, double, double, double)} method, + * with {@code ymin} assumed to be zero. */ - @Override - public int getItemCount() { - return getSkinnable().getImageHeight(); + private double valuesRegionY() { + return topBackground.getHeight(); } /** * Called during the layout pass of the scene graph. The (x,y) coordinates are usually zero * and the (width, height) are the size of the control as shown (not the full content size). - * Current implementation sets the virtual flow size to the given size. + * Current implementation assume that the visible part is the given size. + * + * <h4>Assumptions</h4> + * This implementation ignores {@code xmin} and {@code ymin} on the assumption that they are always zero. + * If this assumption is false, we need to add those values in pretty much everything that set a position + * in this class. */ @Override - protected void layoutChildren(final double x, final double y, final double width, final double height) { + @SuppressWarnings("LocalVariableHidesMemberVariable") + protected void layoutChildren(final double xmin, final double ymin, final double width, final double height) { + // Do not invoke `super.layoutChildren(…)` because we manage all children outselves. + clip.setX(xmin); + clip.setY(xmin); + clip.setWidth(width); + clip.setHeight(height); + + final double sy = height - Styles.SCROLLBAR_HEIGHT; + xScrollBar.resizeRelocate(0, sy, width, Styles.SCROLLBAR_HEIGHT); + yScrollBar.resizeRelocate(width - Styles.SCROLLBAR_WIDTH, 0, Styles.SCROLLBAR_WIDTH, sy); + leftBackground.setHeight(height); + topBackground .setWidth (width); + + final double valuesRegionX = valuesRegionX(); + final double valuesRegionY = valuesRegionY(); + final int nx = Math.max(0, (int) Math.ceil((width - valuesRegionX - Styles.SCROLLBAR_WIDTH) / cellWidth + 1)); + final int ny = Math.max(0, (int) Math.ceil((height - valuesRegionY - Styles.SCROLLBAR_HEIGHT) / cellHeight + 1)); + final int n = Math.multiplyExact(nx, ny); + + // Intentionally use `GridError` instead of `Node` for detecting errors. + final GridError[] errors = errors().toArray(GridError[]::new); + final Text[] headerRow = resize(this.headerRow, nx, true); + final Text[] headerColumn = resize(this.headerColumn, ny, true); + final Text[] valueCells = resize(this.valueCells, n, false); + final Node[] all = new Node[headerRow.length + headerColumn.length + valueCells.length + errors.length + 7]; + + // Order matter: nodes added last will hide nodes added first. + int i = 0; + all[i++] = selection; + System.arraycopy(valueCells, 0, all, i, n); + final int indexOfFirstError = i += n; + System.arraycopy(errors, 0, all, i, errors.length); + final int indexAfterLastError = i += errors.length; + all[i++] = topBackground; + all[i++] = selectedColumn; + all[i++] = leftBackground; + all[i++] = selectedRow; + System.arraycopy(headerRow, 0, all, i, nx); i += nx; + System.arraycopy(headerColumn, 0, all, i, ny); i += ny; + all[i++] = xScrollBar; + all[i++] = yScrollBar; + if (i != all.length) { + throw new AssertionError(i); + } /* - * Super-class only invokes `updateItemCount()` if needed. - * It does not perform any layout by itself in this method. + * It is important to ensure that all nodes are unmanaged, otherwise adding the nodes + * causes a new layout attempt, which causes infinite enqueued calls of this method + * in the JavaFX events thread (the application is not frozen, but wastes CPU). */ - super.layoutChildren(x, y, width, height); + for (Node node : all) { + node.setManaged(false); + } + getChildren().setAll(all); + this.headerRow = headerRow; // Save only after the above succeeded. + this.headerColumn = headerColumn; + this.valueCells = valueCells; + this.indexOfFirstError = indexOfFirstError; + this.indexAfterLastError = indexAfterLastError; final GridView view = getSkinnable(); - double cellSpacing = Math.min(view.cellSpacing.get(), cellWidth); - if (!(cellSpacing >= 0)) cellSpacing = 0; // Use ! for catching NaN (cannot use Math.max). - /* - * Do layout of the flow first because it may cause scroll bars to appear or disappear, - * which may change the size calculations done after that. The flow is located below the - * header row, so we adjust y and height accordingly. - */ - final Flow flow = (Flow) getVirtualFlow(); - final double cellHeight = flow.getFixedCellSize(); - final double headerHeight = cellHeight + 2*cellSpacing; - final double dataY = y + headerHeight; - final double dataHeight = height - headerHeight; - layoutAll |= (flow.getWidth() != width) || (flow.getHeight() != dataHeight); - flow.resizeRelocate(x, dataY, width, dataHeight); - /* - * Recompute all values which will be needed by GridRowSkin. They are mostly information about - * the horizontal dimension, because the vertical dimension is already managed by VirtualFlow. - * We compute here for avoiding to recompute the same values in each GridRowSkin instance. - */ - final double oldPos = leftPosition; - headerWidth = GridView.getSizeValue(view.headerWidth); - cellWidth = GridView.getSizeValue(view.cellWidth); - cellInnerWidth = cellWidth - cellSpacing; - leftPosition = flow.getHorizontalPosition(); // Horizontal position in the virtual view. - rightPosition = leftPosition + width; // Horizontal position where to stop. - firstVisibleColumn = (int) (leftPosition / cellWidth); // Zero-based column index in the image. + view.scaleScrollBar(xScrollBar, nx, false); + view.scaleScrollBar(yScrollBar, ny, true); + updateCellValues(); + } + + /** + * Formats the values in all cells. This method is invoked when a new image is set or after scrolling. + * It can also be invoked when the band to show has changed. + */ + final void updateCellValues() { + @SuppressWarnings("LocalVariableHidesMemberVariable") + final double cellWidth = this.cellWidth, + cellHeight = this.cellHeight; + + final double valuesRegionX = valuesRegionX(); + final double valuesRegionY = valuesRegionY(); + double xminOfValues = xScrollBar.getValue(); + double yminOfValues = yScrollBar.getValue(); + double xposOfValues = valuesRegionX - (xminOfValues - (xminOfValues = Math.floor(xminOfValues))) * cellWidth; + double yposOfValues = valuesRegionY - (yminOfValues - (yminOfValues = Math.floor(yminOfValues))) * cellHeight; + final long xmin = (long) xminOfValues; // Those `double` values are already integers at this point. + final long ymin = (long) yminOfValues; /* - * Set the rectangle position before to do final adjustment on cell position, - * because the background to fill should include the `cellSpacing` margin. + * Render the header column. That column has its own width, + * different than the width of all other cells. */ - topBackground .setX(x); // As a matter of principle, but should be zero. - topBackground .setY(y); - topBackground .setWidth(width); - topBackground .setHeight(headerHeight); - leftBackground.setX(x); - leftBackground.setY(dataY); - leftBackground.setWidth(headerWidth); - leftBackground.setHeight(flow.getVisibleHeight()); - selection .setWidth (cellWidth); - selectedRow .setWidth (headerWidth); - selectedColumn.setWidth (cellWidth); - selection .setHeight(cellHeight); - selectedRow .setHeight(cellHeight); - selectedColumn.setHeight(headerHeight); - selectedRow .setX(x); - selectedColumn.setY(y); - if (cellSpacing < headerWidth) { - headerWidth -= cellSpacing; - leftPosition += cellSpacing; + double cellInnerWidth = toValidCellSize(valuesRegionX - HEADER_MARGIN); + final GridView view = getSkinnable(); + Text[] cells = headerColumn; + final int ny = cells.length; + for (int y=0; y<ny; y++) { + final double ypos = yposOfValues + cellHeight*y; + setRightAlignedText(null, cells[y], view.formatCoordinateValue(ymin + y), 0, ypos, cellInnerWidth, cellHeight); } /* - * Reformat the row header if its content changed. It may be because a horizontal scroll has been - * detected (in which case values changed), or because the view size changed (in which case cells - * may need to be added or removed). + * Compute the width of all cells other than the header column, + * then render the header row. Finally, render the cell values. */ - if (layoutAll || oldPos != leftPosition) { - layoutInArea(headerRow, x, y, width, headerHeight, - Node.BASELINE_OFFSET_SAME_AS_HEIGHT, HPos.LEFT, VPos.TOP); - final ObservableList<Node> children = headerRow.getChildren(); - final int count = children.size(); - final int missing = (int) Math.ceil((width - headerWidth) / cellWidth) - count; - if (missing != 0) { - if (missing < 0) { - children.remove(missing + count, count); // Too many children. Remove the extra ones. - } else { - final GridCell[] more = new GridCell[missing]; - final Font font = Font.font(null, FontWeight.BOLD, -1); - for (int i=0; i<missing; i++) { - final GridCell cell = new GridCell(); - cell.setFont(font); - more[i] = cell; - } - children.addAll(more); // Single addAll(…) operation for sending only one event. + cellInnerWidth = cellWidth; + double cellSpacing = view.cellSpacing.get(); + if (cellSpacing > 0) { + cellInnerWidth = toValidCellSize(cellInnerWidth - cellSpacing); + } + cells = headerRow; + final int nx = cells.length; + for (int x=0; x<nx; x++) { + final double xpos = xposOfValues + cellWidth*x; + setRightAlignedText(null, cells[x], view.formatCoordinateValue(xmin + x), xpos, 0, cellInnerWidth, valuesRegionY); + } + cells = valueCells; +redo: for (;;) { + for (int i=0; i<cells.length; i++) { + final int x = i % nx; + final int y = i / nx; + final double xpos = xposOfValues + x * cellWidth; + final double ypos = yposOfValues + y * cellHeight; + if (setRightAlignedText(view, cells[i], view.formatSampleValue(xmin+x, ymin+y), + xpos, ypos, cellInnerWidth, cellHeight)) + { + // The format pattern has been made shorter. Rewrite previous cells with the new pattern. + continue redo; } } - double pos = x + headerWidth; - int column = firstVisibleColumn; - for (final Node cell : children) { - ((GridCell) cell).setText(view.formatHeaderValue(column++, false)); - layoutInArea(cell, pos, y, cellWidth, headerHeight, Node.BASELINE_OFFSET_SAME_AS_HEIGHT, HPos.RIGHT, VPos.CENTER); - pos += cellWidth; - } - /* - * For a mysterious reason, all row headers except the first one (0) are invisible on the first time - * that the grid is shown. I have been unable to identify the reason; all `GridCell` are created and - * received a non-empty text string. Doing a full layout again makes them appear. So as a workaround - * we request the next layout to be full again if it seems that we have done the initial layout. The - * very first layout create one cell (count = 0 & missing = 1), the next layout create missing cells - * (count = 1 & missing = 18) — this is where we want to force a third layout — then the third layout - * is stable (count = 19 & missing = 0). - */ - layoutAll = (count <= missing); + break; } /* - * Update position of the highlights at mouse cursor position. Usually the correction computed below is - * zero and this block does not change any position (but it may change the geographic coordinates shown - * in status bar). However if the user was scrolling and reached the end of the virtial flow, the last - * scrolling action may have caused a displacement which is a fractional number of cells, in which case - * the highlights appear misaligned if we do not apply the correction below. + * Relocate the error boxes. Usually, there is none. */ - if (selection.isVisible()) { - GridRow row = flow.getFirstVisibleCell(); - if (row != null) { - double sy = selection.getY() - (row.getLayoutY() + headerHeight); - final int i = ((int) Math.rint(sy / cellHeight)) + row.getIndex(); - row = flow.getCell(i); // Empty cell if beyond the range. - sy = row.getLayoutY() + headerHeight; // Usually same as `selection.y` (see above comment). - final double offset = row.getLayoutX() + leftBackground.getWidth(); - final double column = Math.rint((selection.getX() - offset) / cellWidth); - final double sx = column * cellWidth + offset; - selection.setX(sx); - selection.setY(sy); - selectedRow.setY(sy); - selectedColumn.setX(sx); - getSkinnable().formatCoordinates(firstVisibleColumn + (int) column, i); + if (indexOfFirstError != indexAfterLastError) { + final var viewArea = new java.awt.Rectangle(Numerics.clamp(xmin), Numerics.clamp(ymin), nx-1, ny-1); + for (Node node : errors()) { + boolean visible = false; + final var error = (GridError) node; + final var area = error.getVisibleRegion(viewArea); + if (!area.isEmpty()) { + error.resizeRelocate( + (area.x - xmin) * cellWidth + xposOfValues, + (area.y - ymin) * cellHeight + yposOfValues, + area.width * cellWidth, + area.height * cellHeight); + /* + * If after layout the error message size appears too small, hide it. + */ + visible = error.getHeight() >= MIN_ERROR_BOX_SIZE + && error.getWidth() >= MIN_ERROR_BOX_SIZE; + } + error.setVisible(visible); } } - if (hasErrors) { - computeErrorBounds(flow); - } } /** - * If an error exists somewhere, computes as estimation of the visible region - * as zero-based column and row indices. We use an AWT rectangle instead of - * JavaFX object because this rectangle will be intersected with AWT rectangle. + * Sets the text and sets its position for making it aligned on the right. + * If the text does not fit in the space specified by {@code width}, then + * this method tries to format the number using a shorter pattern. + * If this method cannot use a shorter pattern, then the text is truncated. + * + * <p>This method returns {@code true} if it has shortened the pattern. + * In such case, the caller should rewrite all cells in order to use a + * consistent pattern.</p> + * + * @param view the view for which cells are rendered, or {@code null} for not shortening the pattern. + * @param cell the cell where to set the text. + * @param value the text to set. + * @param x horizontal position of the cell. + * @param y vertical position of the cell. + * @param width width of the cell. + * @param height height of the cell. */ - private void computeErrorBounds(final Flow flow) { - final java.awt.Rectangle viewArea = new java.awt.Rectangle(); - final GridRow first = flow.getFirstVisibleCell(); - int firstVisibleRow = 0; - if (first != null) { - viewArea.x = firstVisibleColumn; - viewArea.y = firstVisibleRow = first.getIndex(); - viewArea.width = (int) ((flow.getWidth() - leftBackground.getWidth()) / cellWidth); - viewArea.height = (int) (flow.getVisibleHeight() / flow.getFixedCellSize()); - } - final ObservableList<Node> children = getChildren(); - for (int i=children.size(); --i >= 0;) { - final Node node = children.get(i); - if (!(node instanceof GridError)) break; - final GridError error = (GridError) node; - boolean visible = false; - final java.awt.Rectangle area = error.getVisibleRegion(viewArea); - if (!area.isEmpty()) { - final double cellHeight = flow.getFixedCellSize(); - final double width = area.width * cellWidth; - final double height = area.height * cellHeight; - layoutInArea(error, - cellWidth * (area.x - firstVisibleColumn) + leftBackground.getWidth(), - cellHeight * (area.y - firstVisibleRow) + topBackground.getHeight(), - width, height, Node.BASELINE_OFFSET_SAME_AS_HEIGHT, HPos.CENTER, VPos.CENTER); - /* - * If after layout the error message size appears too large for the remaining space, hide it. - * The intent is to avoid having the message and buttons on top of cell values in valid tiles. - * It does not seem to work fully however (some overlaps can still happen), so we added some - * inset to mitigate the problem and also for more aerated presentation. - */ - visible = error.getHeight() <= height && error.getWidth() <= width; + private static boolean setRightAlignedText(GridView view, final Text cell, String value, + double x, double y, final double width, final double height) + { + boolean redo = false; + cell.setText(value); + if (value != null) { + double dx, dy; + for (;;) { + Bounds bounds = cell.getLayoutBounds(); + dy = height - bounds.getHeight(); + dx = width - bounds.getWidth(); + if (dx >= 0) break; + if (view != null) { + if (view.cellFormat.shorterPattern()) { + redo = true; + break; + } + view = null; + } + int cut = value.length() - 2; + if (cut < 0) break; + value = value.substring(0, cut) + '…'; + cell.setText(value); } - error.setVisible(visible); + x += dx; + y += dy * 0.5; } + cell.relocate(x, y); + return redo; } } diff --git a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Styles.java b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Styles.java index 52d2ef15db..b2255ec381 100644 --- a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Styles.java +++ b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Styles.java @@ -44,10 +44,15 @@ import static org.apache.sis.gui.internal.LogHandler.LOGGER; */ public final class Styles { /** - * Approximate size of vertical scroll bar. + * Approximate width of the vertical scroll bar. */ public static final int SCROLLBAR_WIDTH = 20; + /** + * Approximate height of the horizontal scroll bar. + */ + public static final int SCROLLBAR_HEIGHT = 20; + /** * "Standard" height of table rows. Can be approximate. */ @@ -164,9 +169,9 @@ public final class Styles { * @return a pane with each (label, control) pair on a row. */ public static GridPane createControlGrid(int row, final Label... controls) { - final GridPane gp = new GridPane(); - final ColumnConstraints labelColumn = new ColumnConstraints(); - final ColumnConstraints controlColumn = new ColumnConstraints(); + final var gp = new GridPane(); + final var labelColumn = new ColumnConstraints(); + final var controlColumn = new ColumnConstraints(); labelColumn .setHgrow(Priority.NEVER); controlColumn.setHgrow(Priority.ALWAYS); gp.getColumnConstraints().setAll(labelColumn, controlColumn); @@ -192,8 +197,8 @@ public final class Styles { * @param gp the grid pane in which to set row constraints. */ public static void setAllRowToSameHeight(final GridPane gp) { - final RowConstraints[] constraints = new RowConstraints[gp.getRowCount()]; - final RowConstraints c = new RowConstraints(); + final var constraints = new RowConstraints[gp.getRowCount()]; + final var c = new RowConstraints(); c.setPercentHeight(100.0 / constraints.length); Arrays.fill(constraints, c); gp.getRowConstraints().setAll(constraints); diff --git a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/GridViewApp.java b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/GridViewApp.java index f7495c58ea..9a043cb10d 100644 --- a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/GridViewApp.java +++ b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/GridViewApp.java @@ -65,8 +65,8 @@ public final class GridViewApp extends Application { */ @Override public void start(final Stage window) { - final GridView view = new GridView(); - final BorderPane pane = new BorderPane(view); + final var view = new GridView(); + final var pane = new BorderPane(view); window.setTitle("GridView Test"); window.setScene(new Scene(pane)); window.setWidth (400); @@ -91,7 +91,7 @@ public final class GridViewApp extends Application { * have artificial errors in order to see the error controls. */ private static TiledImageMock createImage() { - final TiledImageMock image = new TiledImageMock( + final var image = new TiledImageMock( DataBuffer.TYPE_USHORT, 1, -50, // minX 70, // minY
