/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2008-2011, Open Source Geospatial Foundation (OSGeo)
*
* 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.geotools.swing.dialog;
import java.awt.Toolkit;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.StringSelection;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.lang.ref.WeakReference;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFileChooser;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingUtilities;
import javax.swing.filechooser.FileFilter;
import javax.swing.text.BadLocationException;
import net.miginfocom.swing.MigLayout;
import org.geotools.util.logging.Logging;
/**
* Displays a text report dialog with options to copy text to the system clipboard or
* save to file. It is used within the gt-swing module (for example, by the
* {@linkplain org.geotools.swing.tool.InfoTool} class) and is also suitable for general use.
* This class is not a Swing component itself, rather it is a dialog manager which allows an
* application to create and update text reporter dialogs from any thread (not just the
* AWT Event Dispatch Thread).
* <p>
* Dialogs are created using the various static {@code showDialog} methods. For example,
* this code creates and shows a dialog displaying the given text:
*
* <pre><code>
* String textToDisplay = ...
* JTextReporter.showDialog("My very important report", text);
* </code></pre>
*
* Dialog behaviour can be specified with those {@code showDialog} methods which accept
* a {@code flags} argument. The dialog in the above example will have the default state
* (non-modal; resizable; always on top of other windows) as specified by the
* {@linkplain #DEFAULT_FLAGS} constant. If we wanted to display the text in a modal
* dialog we can do this:
*
* <pre><code>
* String textToDisplay = ...
* JTextReporter.showDialog("My very important report", text,
* JTextReporter.FLAG_MODAL | FLAG_RESIZEABLE);
* </code></pre>
*
* As well as displaying fixed text, you can also append text to the dialog's display while
* it is on-screen. Each of the {@code showDialog} methods returns a
* {@linkplain Connection} object (a nested class within {@code JTextReporter}) which provides
* methods to append text safely from any thread:
*
* <pre><code>
* Connection conn = JTextReporter.showDialog("Progressive report");
*
* // Append some text to the dialog's display
* conn.append("First line of the report").appendNewline();
*
* // Later add some more text
* conn.append("Next line of the report").appendNewline();
* </code></pre>
*
* A Connection object only keeps a {@linkplain WeakReference} to the associated dialog
* to avoid memory leaks. If an attempt is made to append text after the user has closed
* the dialog an error message is logged indicating that the connection has expired.
* <p>
*
* The {@linkplain Connection} also lets you add listeners to track when the text
* reporter is updated or closed:
*
* <pre><code>
* Connection conn = JTextReporter.showDialog("Progressive report");
* conn.addListener(new TextReporterListener() {
* @Override
* public void onReporterClosed() {
* // do something
* }
*
* @Override
* public void onReporterUpdated() {
* // do something
* }
* });
* </code></pre>
*
*
* @author Michael Bedward
* @since 2.6
*
* @source $URL$
* @version $URL$
*/
public class JTextReporter {
private static final Logger LOGGER = Logging.getLogger("org.geotools.swing");
/**
* Maximum permissable time for dialog creation.
*/
private static final long DIALOG_CREATION_TIMEOUT = 1000;
/**
* Constant indicating that a text reporter should be displayed as a modal dialog.
* Use with {@code showDialog} methods which take a {@code flags} argument.
*/
public static final int FLAG_MODAL = 1;
/**
* Constant indicating that a text reporter should stay on top of other application windows.
* Use with {@code showDialog} methods which take a {@code flags} argument.
*/
public static final int FLAG_ALWAYS_ON_TOP = 1 << 1;
/**
* Constant indicating that a text reporter dialog should be resizable.
* Use with {@code showDialog} methods which take a {@code flags} argument.
*/
public static final int FLAG_RESIZABLE = 1 << 2;
/**
* Default flags argument for {@code showDialog} methods.
* Equivalent to {@code FLAG_ALWAYS_ON_TOP | FLAG_RESIZABLE}.
*/
public static final int DEFAULT_FLAGS = FLAG_ALWAYS_ON_TOP | FLAG_RESIZABLE;
/**
* Default number of rows shown in the text display area's
* preferred size
*/
public static final int DEFAULT_TEXTAREA_ROWS = 20;
/**
* Default number of columns shown in the text display area's
* preferred size
*/
public static final int DEFAULT_TEXTAREA_COLS = 50;
/**
* System-dependent newline character(s).
*/
public static String NEWLINE = System.getProperty("line.separator");
/**
* Default character to use for the {@linkplain Connection#appendSeparatorLine(int)} method.
*/
public static final char DEFAULT_SEPARATOR_CHAR = '-';
/**
* A connection to an active text reporter dialog providing methods to
* update the text displayed, add or remove listeners, and close the
* dialog programatically.
*/
public static class Connection {
private static enum StateChange {
TEXT_UPDATED,
DIALOG_CLOSED;
}
private final WeakReference<TextDialog> dialogRef;
private final List<TextReporterListener> listeners;
private final ReadWriteLock updateLock;
private final AtomicBoolean active;
/**
* Private constructor.
*
* @param dialog the dialog to connect to
*/
private Connection(TextDialog dialog) {
dialogRef = new WeakReference<TextDialog>(dialog);
listeners = new ArrayList<TextReporterListener>();
updateLock = new ReentrantReadWriteLock();
active = new AtomicBoolean(true);
}
/**
* Queries whether this is an open connection, ie. the associated
* dialog has not been closed.
*/
public boolean isOpen() {
updateLock.readLock().lock();
try {
return active.get();
} finally {
updateLock.readLock().unlock();
}
}
/**
* Adds a listener.
*
* @param listener the listener
* @throws IllegalArgumentException if {@code listener} is {@code null}
*/
public void addListener(TextReporterListener listener) {
if (listener == null) {
throw new IllegalArgumentException("listener must not be null");
}
updateLock.writeLock().lock();
try {
if (!listeners.contains(listener)) {
listeners.add(listener);
}
} finally {
updateLock.writeLock().unlock();
}
}
/**
* Removes the listener if it is registered with this connection.
*
* @param listener the listener
* @throws IllegalArgumentException if {@code listener} is {@code null}
*/
public void removeListener(TextReporterListener listener) {
if (listener == null) {
throw new IllegalArgumentException("listener must not be null");
}
updateLock.writeLock().lock();
try {
listeners.remove(listener);
} finally {
updateLock.writeLock().unlock();
}
}
/**
* Removes all currently registered listeners.
*/
public void removeAllListeners() {
updateLock.writeLock().lock();
try {
listeners.clear();
} finally {
updateLock.writeLock().unlock();
}
}
public Connection append(String text) {
return append(text, 0);
}
public Connection append(final String text, final int indent) {
updateLock.writeLock().lock();
try {
final TextDialog dialog = dialogRef.get();
if (dialog == null) {
LOGGER.severe("Appending text to an expired JTextReporter connection");
} else {
if (SwingUtilities.isEventDispatchThread()) {
dialog.append(text, indent);
} else {
doAppendOnEDT(dialog, text, indent);
}
fireEvent(StateChange.TEXT_UPDATED);
}
return this;
} finally {
updateLock.writeLock().unlock();
}
}
/**
* Appends a line of repeated {@link #DEFAULT_SEPARATOR_CHAR}
* followed by a newline.
*/
public Connection appendSeparatorLine(int n) {
return appendSeparatorLine(n, DEFAULT_SEPARATOR_CHAR);
}
/**
* Appends a line consisting of {@code n} copies of char {@code c}
* followed by a newline.
*/
public Connection appendSeparatorLine(int n, char c) {
char[] carray = new char[n];
Arrays.fill(carray, c);
append(String.valueOf(carray));
appendNewline();
return this;
}
/**
* Appends a newline.
*/
public Connection appendNewline() {
append(NEWLINE);
return this;
}
/**
* Gets the currently displayed text.
*/
public String getText() {
updateLock.readLock().lock();
final String[] rtnText = new String[1];
try {
final TextDialog dialog = dialogRef.get();
if (dialog == null) {
LOGGER.severe("Retrieving text from an expired JTextReporter connection");
} else {
if (SwingUtilities.isEventDispatchThread()) {
rtnText[0] = dialog.getText();
} else {
try {
SwingUtilities.invokeAndWait(new Runnable() {
@Override
public void run() {
rtnText[0] = dialog.getText();
}
});
} catch (InterruptedException ex) {
LOGGER.severe("Thread interrupted while getting text from text reporter");
rtnText[0] = "";
} catch (InvocationTargetException ex) {
LOGGER.log(Level.SEVERE, "Error while trying to get text from text reporter", ex);
rtnText[0] = "";
}
}
}
} finally {
updateLock.readLock().unlock();
return rtnText[0];
}
}
/**
* Closes the associated dialog.
* The close operation is run on the event dispatch thread to try to avoid
* collisions with GUI actions, but you can call this method from
* any thread.
* <p>
* It is safe to call this method speculatively: a {@linkplain Level#INFO}
* message will be logged but no error thrown.
*/
public void closeDialog() {
updateLock.writeLock().lock();
try {
if (active.get()) {
final TextDialog dialog = dialogRef.get();
if (dialog != null) {
SwingUtilities.invokeAndWait(new Runnable() {
@Override
public void run() {
dialog.closeDialog();
}
});
}
} else {
LOGGER.info("This connection has expired");
}
} catch (InterruptedException ex) {
LOGGER.severe("Thread interrupted while attmpting to close text reporter");
} catch (InvocationTargetException ex) {
LOGGER.log(Level.SEVERE, "Error while trying to close text reporter", ex);
} finally {
if (active.get()) {
setDialogClosed();
}
updateLock.writeLock().unlock();
}
}
private void doAppendOnEDT(final TextDialog dialog,
final String text,
final int indent) {
try {
SwingUtilities.invokeAndWait(new Runnable() {
@Override
public void run() {
dialog.append(text, indent);
}
});
} catch (InterruptedException ex) {
LOGGER.severe("Interrupted while appending text");
} catch (InvocationTargetException ex) {
LOGGER.log(Level.SEVERE, "Unable to append text", ex);
}
}
/**
* Called by the associated TextDialog when it is closing
*/
private void setDialogClosed() {
updateLock.writeLock().lock();
try {
dialogRef.clear();
active.set(false);
fireEvent(StateChange.DIALOG_CLOSED);
} finally {
removeAllListeners();
updateLock.writeLock().unlock();
}
}
/**
* Informs listeners of changed state.
*
* @param type type of change
*/
private void fireEvent(StateChange type) {
for (TextReporterListener listener : listeners) {
switch (type) {
case DIALOG_CLOSED:
listener.onReporterClosed();
break;
case TEXT_UPDATED:
listener.onReporterUpdated();
break;
}
}
}
}
/**
* Creates a displays a new text reporter dialog.
* <p>
* This method can be called safely from any thread.
*
* @param title dialog title (may be {@code null} or empty
*
* @return a {@linkplain Connection} via which the text displayed by the
* dialog can be updated
*/
public static Connection showDialog(String title) {
return showDialog(title, null);
}
/**
* Creates a displays a new text reporter dialog.
* <p>
* This method can be called safely from any thread.
*
* @param title dialog title (may be {@code null} or empty
* @param initialText text to display initially (may be {@code null} or empty
*
* @return a {@linkplain Connection} via which the text displayed by the
* dialog can be updated
*/
public static Connection showDialog(String title, String initialText) {
return showDialog(title, initialText, DEFAULT_FLAGS);
}
/**
* Creates a displays a new text reporter dialog.
* <p>
* This method can be called safely from any thread.
*
* @param title dialog title (may be {@code null} or empty
* @param initialText text to display initially (may be {@code null} or empty
* @param flags
*
* @return a {@linkplain Connection} via which the text displayed by the
* dialog can be updated
*/
public static Connection showDialog(String title,
String initialText,
int flags) {
return showDialog(title, initialText, flags, DEFAULT_TEXTAREA_ROWS, DEFAULT_TEXTAREA_COLS);
}
public static Connection showDialog(final String title,
final String initialText,
final int flags,
final int textAreaRows,
final int textAreaCols) {
final Connection[] conn = new Connection[1];
if (SwingUtilities.isEventDispatchThread()) {
conn[0] = doShowDialog(title, initialText, flags, textAreaRows, textAreaCols);
} else {
final CountDownLatch latch = new CountDownLatch(1);
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
conn[0] = doShowDialog(title, initialText, flags, textAreaRows, textAreaCols);
latch.countDown();
}
});
try {
latch.await(DIALOG_CREATION_TIMEOUT, TimeUnit.MILLISECONDS);
} catch (InterruptedException ex) {
LOGGER.severe("Thread interrupted while setting up text reporter");
return null;
}
}
return conn[0];
}
private static Connection doShowDialog(final String title,
final String initialText,
final int flags,
final int textAreaRows,
final int textAreaCols) {
TextDialog dialog = new TextDialog(title, initialText, flags, textAreaRows, textAreaCols);
Connection conn = new Connection(dialog);
dialog.setConnection(conn);
DialogUtils.showCentred(dialog);
return conn;
}
static class TextDialog extends AbstractSimpleDialog {
private final JTextArea textArea;
/** Helper method for constructor. */
private static boolean isFlagSet(int flags, int testFlag) {
return (flags & testFlag) > 0;
}
/**
* Private constructor.
* @param title dialog title
* @param initialText initial text to display
* @param flags dialog state flags
* @param textAreaRows number of text area rows
* @param textAreaCols number of text area columns
*/
private TextDialog(String title,
String initialText,
int flags,
int textAreaRows,
int textAreaCols) {
super((JDialog) null, title, isFlagSet(flags, FLAG_MODAL), isFlagSet(flags, FLAG_RESIZABLE));
setAlwaysOnTop(isFlagSet(flags, FLAG_ALWAYS_ON_TOP));
textArea = new JTextArea(textAreaRows, textAreaCols);
initComponents();
if (initialText != null && initialText.length() > 0) {
append(initialText);
}
}
/**
* Establishes a link between this dialog instance and a Connection instance.
*
* @param conn the connection associated with this dialog
*/
private void setConnection(final Connection conn) {
/*
* The dialog informs its Connection object when closing.
* We override both windowClosing and windowClosed methods: the
* former is called when the dialog is closed via the system button
* and the latter when it is closed using the dialog Close button.
*/
addWindowListener(new WindowAdapter() {
private boolean flag = false;
@Override
public void windowClosing(WindowEvent e) {
if (!flag) {
conn.setDialogClosed();
flag = true;
}
}
@Override
public void windowClosed(WindowEvent e) {
if (!flag) {
conn.setDialogClosed();
flag = true;
}
}
});
}
@Override
public JPanel createControlPanel() {
textArea.setEditable(false);
textArea.setLineWrap(true);
textArea.setAutoscrolls(true);
JScrollPane scrollPane = new JScrollPane(textArea);
JPanel panel = new JPanel(new MigLayout("wrap 1", "[grow]", "[grow][]"));
panel.add(scrollPane, "grow");
return panel;
}
@Override
protected JPanel createButtonPanel() {
JPanel panel = new JPanel(new MigLayout());
JButton copyBtn = new JButton("Copy to clipboard");
copyBtn.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
onCopyToClipboard();
}
});
panel.add(copyBtn, "align center");
JButton saveBtn = new JButton("Save...");
saveBtn.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
onSave();
}
});
panel.add(saveBtn);
JButton clearBtn = new JButton("Clear");
clearBtn.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
onClear();
}
});
panel.add(clearBtn);
JButton closeBtn = new JButton("Close");
closeBtn.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
closeDialog();
}
});
panel.add(closeBtn);
return panel;
}
@Override
public void onOK() {
closeDialog();
}
private void onCopyToClipboard() {
if (textArea.getDocument().getLength() > 0) {
StringSelection sel = new StringSelection(textArea.getText());
Clipboard clip = Toolkit.getDefaultToolkit().getSystemClipboard();
clip.setContents(sel, sel);
}
}
private void onSave() {
int len = textArea.getDocument().getLength();
if (len > 0) {
Writer writer = null;
try {
// allow the file chooser to be on top of this dialog
boolean alwaysOnTop = isAlwaysOnTop();
setAlwaysOnTop(false);
File file = FileHelper.getFile();
// restore normal setting
setAlwaysOnTop(alwaysOnTop);
if (file != null) {
writer = new BufferedWriter(new FileWriter(file));
for (int line = 0; line < textArea.getLineCount(); line++) {
int start = textArea.getLineStartOffset(line);
int end = textArea.getLineEndOffset(line);
String lineText = textArea.getText(start, end - start);
if (lineText.endsWith("\n")) {
lineText = lineText.substring(0, lineText.length() - 1);
}
writer.write(lineText);
writer.write(NEWLINE);
}
}
} catch (IOException ex) {
throw new IllegalStateException(ex);
} catch (BadLocationException ex) {
// this should never happen
throw new IllegalStateException("Internal error getting report to save");
} finally {
if (writer != null) {
try {
writer.close();
} catch (IOException ex) {
// having a bad day
}
}
}
}
}
/**
* Clears the current text.
*/
private void onClear() {
int len = textArea.getDocument().getLength();
if (len > 0) {
try {
textArea.getDocument().remove(0, len);
} catch (BadLocationException ex) {
// this shouldn't happen
throw new IllegalStateException(ex);
}
}
}
private void append(String text) {
append(text, 0);
}
/**
* Appends the given text to that displayed. No additional newlines
* are added after the text.
*
* @param text the text to append
* @param indent indent width as number of spaces
*/
private void append(final String text, final int indent) {
int startLine = textArea.getLineCount();
String appendText;
if (indent > 0) {
char[] c = new char[indent];
Arrays.fill(c, ' ');
String pad = String.valueOf(c);
appendText = pad + text.replaceAll("\\n", "\n" + pad);
} else {
appendText = text;
}
textArea.append(appendText);
textArea.setCaretPosition(textArea.getDocument().getLength());
}
/**
* Gets the currently displayed text.
*
* @return currently displayed text
*/
private String getText() {
return textArea.getText();
}
}
/**
* Provides a file chooser dialog which remembers the previous directory.
*/
static class FileHelper {
/* current working directory - for multiple saves */
private static File cwd;
/**
* Displays a file chooser dialog and returns the selected file.
*
* @return the selected file
*/
static File getFile() {
JFileChooser chooser = new JFileChooser(cwd);
chooser.setFileFilter(new FileFilter() {
@Override
public boolean accept(File f) {
return true;
}
@Override
public String getDescription() {
return "All files";
}
});
if (chooser.showSaveDialog(null) != JFileChooser.APPROVE_OPTION) {
return null;
}
cwd = chooser.getCurrentDirectory();
return chooser.getSelectedFile();
}
}
}