/*
* Geotoolkit.org - An Open Source Java GIS Toolkit
* http://www.geotoolkit.org
*
* (C) 2009-2012, Open Source Geospatial Foundation (OSGeo)
* (C) 2009-2012, Geomatys
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotoolkit.gui.swing.image;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.prefs.Preferences;
import java.text.ParseException;
import javax.swing.*;
import java.awt.Dimension;
import java.awt.CardLayout;
import java.awt.GridLayout;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import javax.imageio.spi.ImageReaderSpi;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.table.TableColumnModel;
import javax.swing.table.TableModel;
import org.jdesktop.swingx.JXHeader;
import org.jdesktop.swingx.JXBusyLabel;
import org.geotoolkit.gui.swing.Dialog;
import org.geotoolkit.image.io.mosaic.Tile;
import org.geotoolkit.image.io.mosaic.TileManager;
import org.geotoolkit.image.io.ImageReaderAdapter;
import org.geotoolkit.resources.Vocabulary;
import org.geotoolkit.resources.Descriptions;
import org.geotoolkit.internal.swing.SwingUtilities;
import org.geotoolkit.internal.swing.ExceptionMonitor;
import org.geotoolkit.gui.swing.ProgressWindow;
/**
* A chooser for a set of {@linkplain Tile tiles} to be used for creating a mosaic. This chooser
* allows users to select tiles from a file or a directory. The tiles are images in any format
* supported by Java Image I/O library (TIFF, PNG, <i>etc.</i>), accompanied by their
* <cite>World Files</cite> (text files having the same name than the image files except for the
* extension, which is {@code .tfw}, {@code .jpw}, <i>etc.</i> depending on the image format).
* The silhouette of selected tiles is displayed in the right pane.
* <p>
* As an alternative to the selection of multiple image files, the user can also select a single
* text file having the {@code .txt}, {@code .lst} or {@code .csv} extension. This text file is
* expected to contain a list of image files to use for the mosaic.
* <p>
* <b>Example:</b>
*
* {@preformat java
* MosaicChooser chooser = new MosaicChooser();
* if (chooser.showDialog(null, "Select source tiles")) {
* TileManager[] tiles = chooser.getSelectedTiles();
* // Process here.
* }
* }
*
* @author Martin Desruisseaux (Geomatys)
* @version 3.12
*
* @since 3.00
* @module
*/
@SuppressWarnings("serial")
public class MosaicChooser extends JComponent implements Dialog {
/**
* The preference name for the format of tiles to be read.
*/
private static final String INPUT_FORMAT = "InputTilesFormat";
/**
* The preference name for the format of tiles to be read. This is not used by this class
* ({@link MosaicBuilderEditor} uses it), but is defined here for keeping it close to
* {@link #INPUT_DIRECTORY}.
*/
static final String OUTPUT_FORMAT = "OutputTilesFormat";
/**
* The preference name for the directory of tiles to be read.
*/
private static final String INPUT_DIRECTORY = "InputTilesDirectory";
/**
* The preference name for the directory of tiles to be written. This is not used by this
* class ({@link MosaicBuilderEditor} uses it), but is defined here for keeping it close
* to {@link #INPUT_DIRECTORY}.
*/
static final String OUTPUT_DIRECTORY = "OutputTilesDirectory";
/**
* The table of tiles.
*/
private final MosaicTableModel tiles;
/**
* The main panel, with the tables of tiles on the left side and the graphical
* representation of tiles on the right side.
*/
private final JSplitPane pane;
/**
* The table of failures. Will be created only when needed.
*/
private JTable failures;
/**
* The layout used for the right pane. Used for switching between the
* "busy" pane and the pane displaying the mosaic.
*/
private final CardLayout mosaicLayout;
/**
* The panel where the mosaic is painted.
*/
private final MosaicPanel mosaic;
/**
* The label to animate when a computation is under progress for the right pane.
*/
private final JXBusyLabel busy;
/**
* The loader task being run in background, or {@code null} if no such task is being executed.
* This field shall be set in the Swing thread only, including its reinitialization to null.
*/
private transient Loader loader;
/**
* Creates a new tiles chooser.
*/
public MosaicChooser() {
setLayout(new BorderLayout());
final Vocabulary resources = Vocabulary.getResources(null);
final MosaicPanel mosaic = this.mosaic = new MosaicPanel();
final MosaicTableModel tiles = this.tiles = new MosaicTableModel();
/*
* Builds the tables of tiles. At this stage we build only the table of successfully
* created tiles. Later (in the "reportFailures" method), a table of failures may also
* be created.
*/
final JTable successTable = new JTable(tiles);
successTable.setAutoCreateRowSorter(true);
final TableColumnModel columns = successTable.getColumnModel();
columns.getColumn(0).setPreferredWidth(250); // Gives more space to the column of filenames.
for (int i=2; i<=5; i++) {
// Gives more space to the (width,height,x,y) columns,
// since they typically contain big numbers.
columns.getColumn(i).setPreferredWidth(100);
}
successTable.getSelectionModel().addListSelectionListener(new ListSelectionListener() {
@Override public void valueChanged(final ListSelectionEvent event) {
if (!event.getValueIsAdjusting()) {
mosaic.setSelectedTiles(tiles.getElements(successTable.getSelectedRows()));
}
}
});
/*
* Builds the "Add" and "Remove" buttons, together with their actions.
* Those buttons will be inserted below the table created above.
*/
final JButton add = new JButton(resources.getMenuLabel(Vocabulary.Keys.Add));
final JButton remove = new JButton(resources.getString(Vocabulary.Keys.Remove));
add.addActionListener(new ActionListener() {
@Override public void actionPerformed(final ActionEvent event) {
promptForTiles();
}
});
final class RemoveAction implements ActionListener, ListSelectionListener {
@Override public void actionPerformed(final ActionEvent event) {
tiles.remove(successTable.getSelectedRows());
runLoader(null);
}
@Override public void valueChanged(final ListSelectionEvent event) {
remove.setEnabled(successTable.getSelectedRowCount() != 0);
}
}
final RemoveAction control = new RemoveAction();
remove.addActionListener(control);
successTable.getSelectionModel().addListSelectionListener(control);
remove.setEnabled(false);
final JPanel buttons = new JPanel(new GridLayout(1,2));
buttons.add(add);
buttons.add(remove);
final Box bb = Box.createHorizontalBox();
bb.add(Box.createGlue());
bb.add(buttons);
bb.add(Box.createGlue());
/*
* Builds the left pane, which contains the following:
*
* - The table of successfully loaded tiles.
* - The table of failures while loading tiles (DEFERRED).
* - The "add/remove" buttons.
*
* The creation of the failures table is deferred to the "reportFailures" method.
* That method will assume that the table of successfully created tiles is the
* component at index 1 of the left pane.
*/
final JPanel leftPane = new JPanel(new BorderLayout());
leftPane.add(bb, BorderLayout.SOUTH);
leftPane.add(new JScrollPane(successTable), BorderLayout.CENTER); // Must be last (see above).
/*
* Builds the right pane, which contains the silhouette of the selected tiles. We use a
* a CardLayout in order to switch between the busy state and the display of silhouettes.
*/
mosaicLayout = new CardLayout();
final JPanel rightPane = new JPanel(mosaicLayout);
final JComponent mosaicPane = mosaic.createScrollPane();
mosaicPane.setBorder(BorderFactory.createLoweredBevelBorder());
busy = new JXBusyLabel(new Dimension(32, 32));
busy.setVerticalAlignment(JLabel.CENTER);
busy.setHorizontalAlignment(JLabel.CENTER);
rightPane.add(busy, "Busy");
rightPane.add(mosaicPane, "Mosaic");
/*
* Creates the split pane. The divider location has been empirically determined to
* a value approximatively equals to the preferred table width, in order to allow
* filename and (width,height,x,y) columns to be fully visible. If this value needs
* to be changed, a convenient place where to display a value is in promptForTiles().
*
* We do not set a preferred size for the widget as a whole because the default size
* seems good enough.
*/
pane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, true, leftPane, rightPane);
pane.setDividerLocation(460);
pane.setBorder(null);
add(pane, BorderLayout.CENTER);
}
/**
* Returns the selected tiles as {@code TileManager} objects, or an empty array if none.
* Only one tile manager is usually returned. However more managers may be returned if,
* for example, {@link org.geotoolkit.image.io.mosaic.TileManagerFactory} failed to create
* only one instance from a set of tiles.
*
* @return The selected tiles as {@code TileManager} objects, or an empty array if none.
*/
public TileManager[] getSelectedTiles() {
return (mosaic != null) ? mosaic.getTileManagers() : MosaicPanel.NO_TILES;
}
/**
* Sets whatever the right pane should consider itself busy. The right pane is busy
* after the tiles have been loaded and while the {@link TileManager} is in the process
* of being computed.
*
* @param b {@code true} for displaying the busy label, or {@code false} for displaying
* the mosaic.
*/
private void setBusy(final boolean b) {
busy.setBusy(b);
mosaicLayout.show((JComponent) pane.getRightComponent(), b ? "Busy" : "Mosaic");
}
/**
* Asks the user to supply tiles. The default implementation popups an {@link ImageFileChooser}
* and adds the selected files to the {@link MosaicTableModel}. Duplicated values are removed
* and the remainder entries are sorted. If the creation of some tiles failed, an error dialog
* box is displayed.
*/
private void promptForTiles() {
tiles.locale = getLocale();
final Preferences prefs = Preferences.userNodeForPackage(MosaicChooser.class);
/*
* Setup the image chooser. The directory is set to the one
* used last time this widget was executed, for convenience.
*/
final ImageFileChooser fileChooser;
fileChooser = new ImageFileChooser(prefs.get(INPUT_FORMAT, "png"));
fileChooser.setMultiSelectionEnabled(true);
fileChooser.setListFileFilterUsed(true);
String home = prefs.get(INPUT_DIRECTORY, null);
if (home != null) {
final File directory = new File(home);
if (directory.isDirectory()) {
fileChooser.setCurrentDirectory(directory);
}
}
if (fileChooser.showOpenDialog(this) != ImageFileChooser.APPROVE_OPTION) {
return;
}
final String directory = fileChooser.getCurrentDirectory().getPath();
prefs.put(INPUT_DIRECTORY, directory);
runLoader(fileChooser);
final String format = loader.getFormat();
if (format != null) {
prefs.put(INPUT_FORMAT, format);
}
}
/**
* Runs {@link Loader} in a background thread. This method is invoked when tiles are
* added add when tiles are removed. In the later case, loading of tile headers can
* be skipped.
*
* @param fileChooser The chooser from which to get the selected tiles. If {@code null},
* then loading of tile headers will be skipped - the thread will go directly to
* the creation of the tile manager instead.
*/
private void runLoader(final ImageFileChooser fileChooser) {
if (loader != null) {
loader.cancel(false);
}
loader = new Loader(fileChooser);
loader.execute();
}
/**
* The inner class which perform the {@link MosaicChooser#promptForTiles()} work. The work is
* performed by a single {@link #run} method, but is sliced in many sections to be executed
* in Swing thread or in the background thread. We put everything in the same {@code run()}
* method because the next step uses the results from the previous steps.
*
* @author Martin Desruisseaux (Geomatys)
* @version 3.05
*
* @since 3.00
* @module
*/
private final class Loader extends SwingWorker<Object,Object> {
private final File[] files;
private final ImageReaderSpi provider;
private final ProgressWindow progress;
/**
* Creates a new tile loader.
*
* @param fileChooser The chooser from which to get the selected tiles. If {@code null},
* then loading of tile headers will be skipped - this thread will go directly to
* the creation of the tile manager instead.
*/
Loader(final ImageFileChooser fileChooser) {
if (fileChooser == null) {
files = null;
provider = null;
progress = null;
} else {
files = fileChooser.getSelectedFiles();
provider = ImageReaderAdapter.Spi.unwrap((ImageReaderSpi) fileChooser.getCurrentProvider());
progress = new ProgressWindow(MosaicChooser.this);
}
}
/**
* Returns the format name of the reader used for reading tile headers,
* or {@code null} if it can not be determined. This method is safe for
* invocation from any thread, since it access only immutable fields.
*/
public String getFormat() {
if (provider != null) {
final String[] names = provider.getFormatNames();
if (names != null && names.length != 0) {
return names[0];
}
}
return null;
}
/**
* Invoked in a background thread for loading the tiles. This method sends
* intermediate results through the {@code publish(...)} method, to be
* processed in the <cite>Swing</cite> thread by {@link #process(List)}.
*
* @return Always {@code null}.
*/
@Override
protected Object doInBackground() {
/*
* Creates the Tile objects from the list of files selected by the user. Needs to be
* run in background because this method will open every files for fetching the image
* size and will read every TFW files.
*/
if (files != null) {
final TableModel failures = tiles.add(provider, files, progress);
if (failures != null) {
publish(failures);
}
}
/*
* Set the busy state only when the reading is completed (during the reading,
* the progress was reported in an other window), to show that the process
* which is now under way is the mosaic computation, not tiles reading.
*/
publish(Boolean.TRUE);
/*
* A task to be run in the Swing thread: removes duplicated tiles. This needs to be
* done after the mosaic has been built, because tile locations have been determined
* only after the mosaic creation and may affect the result of Tile.equals(Object).
*/
final class RD implements Runnable {
volatile boolean again = true;
@Override public void run() {
if (tiles.removeDuplicates() == 0) {
again = false;
}
}
}
final RD removeDuplicates = new RD(); // Will be run later.
/*
* Computes the mosaic from the list of tiles. We may need to perform this step
* more than once if we had to remove duplicated tiles, except if an exception
* has been thrown.
*/
Object result;
do {
if (isCancelled()) {
return null;
}
try {
result = tiles.getTileManager();
} catch (IOException e) {
result = e;
removeDuplicates.again = false;
}
SwingUtilities.invokeAndWait(removeDuplicates);
} while (removeDuplicates.again);
publish(Boolean.FALSE, result);
return null;
}
/**
* Processes in the <cite>Swing</cite> thread the intermediate results sent
* by {@link #doInBackground()}. The task depends on the result type:
* <p>
* <ul>
* <li>{@link Boolean}: Set the busy state in the right pane (the mosaic silhouete).</li>
* <li>{@link TileManager}: Display the mosaic silhouete.</li>
* <li>{@link TableModel}: Create a new JTable for displaying the list of failures.</li>
* <li>{@link Throwable}: Display a dialog box reporting the exception.</li>
* </ul>
*
* @param results The intermediate results sent by {@link #doInBackground()}.
*/
@Override
protected void process(final List<Object> results) {
for (final Object result : results) {
if (isCancelled()) {
return;
}
if (result instanceof Boolean) {
setBusy((Boolean) result);
continue;
}
if (result instanceof TileManager[]) {
mosaic.setTileManagers((TileManager[]) result);
continue;
}
if (result instanceof TableModel) {
reportFailures(files, (TableModel) result);
continue;
}
if (result instanceof Throwable) {
ExceptionMonitor.show(MosaicChooser.this, (Throwable) result);
continue;
}
}
}
/**
* Invoked from the Swing thread after we finished to load and compute the mosaic,
* or after a failure.
*/
@Override
protected void done() {
if (!isCancelled()) {
loader = null; // Said to MosaicChooser that we are done.
fireStateChanged();
}
}
}
/**
* If we failed to read some tiles, report the errors to the user. The report will
* contains a table listing the tiles that we failed to read, together with the
* exception message.
*/
private void reportFailures(final File[] files, final TableModel failures) {
if (this.failures != null) {
this.failures.setModel(failures);
} else {
this.failures = new JTable(failures);
final Locale locale = getLocale();
final int count = failures.getRowCount();
final JXHeader label = new JXHeader(
Vocabulary.getResources(locale).getString(Vocabulary.Keys.Error),
Descriptions.getResources(locale).getString(
Descriptions.Keys.ErrorReadingSomeFiles_2, files.length-count, count));
final JPanel panel = new JPanel(new BorderLayout());
panel.add(label, BorderLayout.NORTH);
panel.add(new JScrollPane(this.failures), BorderLayout.CENTER);
/*
* Inserts the failure table in the left pane. We extract the old table
* ("successTable", which is actually a JScrollPane) from the left pane
* and substitute it with a JSplitPane having both tables.
*/
final JComponent leftPane = (JComponent) pane.getLeftComponent();
final JComponent successTable = (JComponent) leftPane.getComponent(1);
final JSplitPane tables = new JSplitPane(JSplitPane.VERTICAL_SPLIT, successTable, panel);
// Note: adding "successTable" in "tables" has implicitly removed it from "leftPane".
tables.setContinuousLayout(true);
tables.setOneTouchExpandable(true);
tables.setDividerLocation(200);
leftPane.add(tables, BorderLayout.CENTER);
leftPane.validate();
}
}
/**
* Adds a listener to be notified when the {@linkplain #getSelectedTiles() selected tiles}
* changed. Those listeners can invoke {@link #getSelectedTiles()} in order to get the new
* selection.
*
* @param listener The listener to add.
*/
public void addChangeListener(final ChangeListener listener) {
listenerList.add(ChangeListener.class, listener);
}
/**
* Removes a listener previously added.
*
* @param listener The listener to remove.
*/
public void removeChangeListener(final ChangeListener listener) {
listenerList.remove(ChangeListener.class, listener);
}
/**
* Invoked when the selection of tiles changed.
*/
private void fireStateChanged() {
final ChangeEvent event = new ChangeEvent(this);
final Object[] listeners = listenerList.getListenerList();
for (int i=listeners.length; (i-=2) >= 0;) {
if (listeners[i] == ChangeListener.class) {
((ChangeListener) listeners[i+1]).stateChanged(event);
}
}
}
/**
* {@inheritDoc}
*
* @since 3.12
*/
@Override
public void commitEdit() throws ParseException {
}
/**
* {@inheritDoc}
*/
@Override
public boolean showDialog(final Component owner, final String title) {
return SwingUtilities.showDialog(owner, this, title);
}
}