blob: a429e4ca1afaea894d24b6008d6505288f7d77e3 [file] [log] [blame]
/*
* 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.dataset;
import java.util.Locale;
import java.util.List;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TableCell;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.Region;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.DefaultProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.util.Callback;
import org.opengis.util.GenericName;
import org.opengis.util.InternationalString;
import org.apache.sis.util.privy.Strings;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.storage.FeatureSet;
import org.apache.sis.feature.privy.AttributeConvention;
import org.apache.sis.gui.internal.IdentityValueFactory;
import org.apache.sis.gui.internal.ExceptionReporter;
import static org.apache.sis.gui.internal.LogHandler.LOGGER;
// Specific to the geoapi-3.1 and geoapi-4.0 branches:
import org.opengis.feature.Feature;
import org.opengis.feature.FeatureType;
import org.opengis.feature.PropertyType;
import org.opengis.feature.AttributeType;
import org.opengis.feature.FeatureAssociationRole;
/**
* A view of {@link FeatureSet} data organized as a table. The features are specified by a call
* to {@link #setFeatures(FeatureSet)}, which will load the features in a background thread.
* At first only a limited number of features are loaded.
* More features will be loaded only when the user scroll down.
*
* <p>If this view is removed from scene graph, then {@link #interrupt()} should be called
* for stopping any loading process that may be under progress.</p>
*
* <h2>Limitations</h2>
* <ul>
* <li>The {@link #itemsProperty() itemsProperty} should be considered read-only,
* and {@link #setItems(ObservableList)} should never be invoked explicitly.
* For changing content, use the {@link #featuresProperty} instead.</li>
* <li>The list returned by {@link #getItems()} should be considered read-only.</li>
* </ul>
*
* @todo This class does not yet handle {@link FeatureAssociationRole}. We could handle them with
* {@link javafx.scene.control.SplitPane} with the main feature table in the upper part and
* the feature table of selected cell in the bottom part. Bottom part could put tables in a
* {@link javafx.scene.control.Accordion} since there is possibly different tables to show
* depending on the column of selected cell.
*
* @author Johann Sorel (Geomatys)
* @author Smaniotto Enzo (GSoC)
* @author Martin Desruisseaux (Geomatys)
* @version 1.4
* @since 1.1
*/
@DefaultProperty("features")
public class FeatureTable extends TableView<Feature> {
/**
* The locale to use for texts. This is usually {@link Locale#getDefault()}.
* This value is given to {@link InternationalString#toString(Locale)} calls.
*/
final Locale textLocale;
/**
* The type of features, or {@code null} if not yet determined.
* This type determines the columns that will be shown.
*
* @see #setFeatureType(FeatureType)
*/
private FeatureType featureType;
/**
* The data shown in this table. Note that setting this property to a non-null value
* does not modify the table content immediately. Instead, a background process will
* load the feature instances.
*
* @see #getFeatures()
* @see #setFeatures(FeatureSet)
*/
public final ObjectProperty<FeatureSet> featuresProperty = new SimpleObjectProperty<>(this, "features");
/**
* Whether the {@link #getItems()} list may be shared by another {@link FeatureTable} instance.
* In such case, {@link #setFeatureType(FeatureType)} should create a new list instead of invoking
* {@link FeatureList#clear()} on the existing list.
*/
private boolean isSharingList;
/**
* Creates an initially empty table.
*/
public FeatureTable() {
super(new FeatureList());
textLocale = Locale.getDefault(Locale.Category.DISPLAY);
initialize();
}
/**
* Creates a new table initialized to the same data as the specified table.
* The two tables will share the same list as long as they are viewing the same data source:
* as the data loading progresses, new features will appear in both tables.
*
* @param other the other table from which to get the feature list.
*/
public FeatureTable(final FeatureTable other) {
super(other.getFeatureList());
other.isSharingList = true;
isSharingList = true;
textLocale = other.textLocale;
featureType = other.featureType;
setFeatures(other.getFeatures()); // Shall be invoked before to install the listener.
initialize(); // Install listener.
if (featureType != null) {
createColumns();
} else if (getFeatures() != null) {
/*
* It may not be possible to create the columns immediately because the table is still loading
* in a background thread. In such case, we will create the columns later when the feature type
* will become known (which we identify by the other feature table updating its own columns).
*/
other.getColumns().addListener(new InvalidationListener() {
@Override public void invalidated(final Observable list) {
list.removeListener(this); // This event is needed only once.
if (other.getFeatures() == getFeatures()) {
featureType = other.featureType;
if (featureType != null) {
createColumns();
}
}
}
});
}
}
/**
* Completes the initialization of this {@link FeatureTable}.
* This is common code shared by both constructors.
*/
private void initialize() {
featuresProperty.addListener((p,o,n) -> startFeaturesLoading(n));
setColumnResizePolicy(CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
setTableMenuButtonVisible(true);
}
/**
* Returns the list where to add features.
* All methods on the returned list shall be invoked from JavaFX thread.
*/
final FeatureList getFeatureList() {
final ObservableList<Feature> items = getItems();
if (items instanceof FeatureList) {
return (FeatureList) items;
} else {
return (FeatureList) ((ExpandableList) items).getSource();
}
}
/**
* Returns the list where to add features when some properties may be multi-valued.
* This method wraps the {@link FeatureList} into an {@link ExpandableList} if needed.
*/
private ExpandableList getExpandableList() {
final ObservableList<Feature> items = getItems();
if (items instanceof ExpandableList) {
return (ExpandableList) items;
} else {
return new ExpandableList((FeatureList) items);
}
}
/**
* Returns the source of features for this table.
*
* @return the features shown in this table, or {@code null} if none.
*
* @see #featuresProperty
*/
public final FeatureSet getFeatures() {
return featuresProperty.get();
}
/**
* Sets the features to show in this table. This method loads an arbitrary number of
* features in a background thread. It does not load all features if the feature set
* is large, unless the user scroll down.
*
* <p>If the loading of another {@code FeatureSet} was in progress at the time this
* method is invoked, then that previous loading process is cancelled.</p>
*
* <p><b>Note:</b> the table content may appear unmodified after this method returns.
* The modifications will appear at an undetermined amount of time later.</p>
*
* @param features the features to show in this table, or {@code null} if none.
*
* @see #featuresProperty
*/
public final void setFeatures(final FeatureSet features) {
featuresProperty.set(features);
}
/**
* Invoked (indirectly) when the user sets a new {@link FeatureSet}.
* See {@link #setFeatures(FeatureSet)} for method description.
*/
private void startFeaturesLoading(final FeatureSet features) {
final FeatureList items;
if (isSharingList) {
items = new FeatureList();
isSharingList = false;
setItems(items);
} else {
items = getFeatureList();
items.interrupt();
}
if (!items.startFeaturesLoading(this, features)) {
featureType = null;
getColumns().clear();
setPlaceholder(null);
}
}
/**
* Invoked in JavaFX thread after the feature type has been determined.
* This method clears all rows and replaces all columns by new columns
* determined from the given type.
*/
final void setFeatureType(final FeatureType type) {
setPlaceholder(null);
getItems().clear();
final boolean update = (type != null) && !type.equals(featureType);
/*
* The feature type must be set before to invoke `createColumns(…)` because it is used not only
* by that method, but also by the listener registered in `FeatureTable(other)` constructor.
*/
featureType = type;
if (update) {
createColumns();
}
}
/**
* Creates table columns for the current {@link #featureType}.
*/
private void createColumns() {
final Collection<? extends PropertyType> properties = featureType.getProperties(true);
final List<TableColumn<Feature,?>> columns = new ArrayList<>(properties.size());
final List<String> multiValued = new ArrayList<>(columns.size());
for (final PropertyType pt : properties) {
/*
* Get localized text to show in column header. Also remember
* the plain property name; it will be needed for ValueGetter.
*/
final GenericName qualifiedName = pt.getName();
final String name = qualifiedName.toString();
String title = string(pt.getDesignation());
if (title == null) {
title = string(qualifiedName.toInternationalString());
if (title == null) title = name;
}
/*
* If the property may contain more than one value, we will
* need a specialized cell getter.
*
* TODO: we should also handle FeatureAssociationRole here.
* See comment in class javadoc.
*/
boolean isMultiValued = false;
if (pt instanceof AttributeType<?>) {
isMultiValued = ((AttributeType<?>) pt).getMaximumOccurs() > 1;
}
if (isMultiValued) {
multiValued.add(name);
}
/*
* Create and configure the column. For multi-valued properties, ValueGetter always
* gives the whole collection. Fetching a particular element in that collection will
* be ElementCell's work.
*/
final TableColumn<Feature,Object> column = new TableColumn<>(title);
column.setCellValueFactory(new ValueGetter(name));
column.setCellFactory(isMultiValued ? ElementCell::new : ValueCell::new);
if (AttributeConvention.contains(qualifiedName)) {
column.setVisible(false); // Hide synthetic properties.
}
columns.add(column);
}
/*
* If there is at least one multi-valued property, insert a column which will contain
* an icon in front of rows having a property with more than one value.
*/
if (multiValued.isEmpty()) {
setItems(getFeatureList()); // Will fire a change event only if the list is not the same.
} else {
final ExpandableList list = getExpandableList();
list.setMultivaluedColumns(multiValued);
final TableColumn<Feature,Feature> column = new TableColumn<>("▤");
column.setCellValueFactory(IdentityValueFactory.instance());
column.setCellFactory(list);
column.setReorderable(false);
column.setSortable (false);
column.setResizable (false);
column.setMinWidth(20);
column.setMaxWidth(20);
columns.add(0, column);
setItems(list);
}
getColumns().setAll(columns); // Change columns in an all or nothing operation.
}
/**
* Given a {@link Feature}, returns the value of the property having the name specified at construction time.
* Note that if the property is multi-valued, then this getter returns the whole collection since we have no
* easy way to know the current row number. Fetching a particular element in that collection will be done by
* {@link ExpandedFeature}.
*/
private static final class ValueGetter implements Callback<TableColumn.CellDataFeatures<Feature,Object>, ObservableValue<Object>> {
/**
* The name of the feature property for which to fetch values.
*/
private final String name;
/**
* Creates a new getter of property values.
*
* @param name name of the feature property for which to fetch values.
*/
ValueGetter(final String name) {
this.name = name;
}
/**
* Returns the value of the feature property wrapped by the given argument.
* This method is invoked by JavaFX when a cell needs to be rendered with a new value.
*/
@Override
public ObservableValue<Object> call(final TableColumn.CellDataFeatures<Feature, Object> cell) {
Object value = null;
final Feature feature = cell.getValue();
if (feature != null) {
value = feature.getPropertyValue(name);
}
return new ReadOnlyObjectWrapper<>(value);
}
}
/**
* A cell displaying a value in {@link FeatureTable}. This base class expects single values.
* If the property values are collections, then {@link ElementCell} should be used instead.
*/
private static class ValueCell extends TableCell<Feature,Object> {
/**
* Creates a new cell for feature property value.
*
* @param column the column where the cell will be shown.
*/
ValueCell(final TableColumn<Feature,Object> column) {
// Column not used at this time, but we need it in method signature.
}
/**
* Invoked when a new value needs to be show.
*
* @todo Needs to check for object type (number, date, etc.).
* Should share with {@link org.apache.sis.gui.metadata.MetadataTree}.
*
* @todo For points, use {@link TableColumn#getColumns() nested columns} (one per dimension)
* with labels fetched from the CRS. For geometries, consider expanding points as we do
* for collections.
*
* @todo For {@link ValueCell} only (not {@link ElementCell}), if the feature is {@link ExpandedFeature}
* with {@code index != 0}, write text in gray. We could also use the value formatted at index 0
* for avoiding to format the same thing many times.
*
* @param value the new item for the cell.
* @param empty whether this cell is used to render an empty row.
*/
@Override
protected void updateItem(final Object value, final boolean empty) {
if (value == getItem()) return;
super.updateItem(value, empty);
String text = null;
if (value != null) {
text = value.toString();
}
setText(text);
}
}
/**
* Fetch single elements from multi-valued properties.
*/
private static final class ElementCell extends ValueCell {
/**
* Creates a new cell for multi-values feature property value.
*
* @param column the column where the cell will be shown.
*/
ElementCell(final TableColumn<Feature,Object> column) {
super(column);
}
/**
* Invoked when a new value needs to be show.
*
* @param value the new item for the cell.
* @param empty whether this cell is used to render an empty row.
*/
@Override
protected void updateItem(Object value, final boolean empty) {
if (value instanceof List<?>) {
final List<?> c = (List<?>) value;
value = c.isEmpty() ? null : c.get(0);
} else if (value instanceof Iterable<?>) {
final Iterator<?> c = ((Iterable<?>) value).iterator();
value = c.hasNext() ? c.next() : null;
}
super.updateItem(value, empty);
}
}
/**
* Returns the given international string as a non-empty localized string, or {@code null} if none.
*/
private String string(final InternationalString i18n) {
return (i18n != null) ? Strings.trimOrNull(i18n.toString(textLocale)) : null;
}
/**
* If a loading process was under way, interrupts it and closes the feature stream.
* This method returns immediately; the release of resources happens in a background thread.
*/
public void interrupt() {
getFeatureList().interrupt();
}
/**
* Replaces the table content by an exception message.
* This method is invoked after a loading process failed.
*/
final void setException(final Throwable exception) {
getItems().clear();
final Region trace = new ExceptionReporter(exception).getView();
StackPane.setAlignment(trace, Pos.TOP_LEFT);
setPlaceholder(trace);
}
/**
* Reports an exception that we cannot display in this widget, for example because it applies
* to different data than the one currently viewed. The {@code method} argument should be the
* public API (if possible) invoking the method where the exception is caught.
*/
static void unexpectedException(final String method, final Throwable exception) {
Logging.unexpectedException(LOGGER, FeatureTable.class, method, exception);
}
/**
* Reports an exception that we choose to ignore.
*/
static void recoverableException(final String method, final Exception exception) {
Logging.recoverableException(LOGGER, FeatureTable.class, method, exception);
}
}