/*
* Copyright © 2010 Martin Riedel
*
* This file is part of TransFile.
*
* TransFile is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TransFile 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TransFile. If not, see <http://www.gnu.org/licenses/>.
*/
package net.sourceforge.transfile.ui.swing;
import java.awt.Color;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.text.ParseException;
import javax.swing.JFormattedTextField;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.event.ChangeEvent;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.AbstractDocument;
import net.sourceforge.transfile.exceptions.LogicError;
import net.sourceforge.transfile.settings.Settings;
//TODO spin off an AsYouTypeSpinner and make it a base class of PortSpinner
//TODO color background, not foreground, when the current value is invalid
//TODO disable both buttons when the value is invalid and re-enable them properly when it becomes valid (with respect to extremes)
//TODO add tooltip explaining range of accepted values
//TODO implement functionality: setCaretPosition(getDocument().getLength() on the JFormattedTextField
// when it gains focus THROUGH TABBING, without interfering with the MouseListener's functionality
/**
* A JSpinner containing a port number. Input checks are performed as the user types. The spinner's value
* is guaranteed to represent the last valid port number the user has entered, and is updated as the user
* types. The user does not have to commit his edit in any way (i.e. by pressing enter or unfocusing the text field).
* This should cater to the user's intuitive expectations much more than the default JSpinner behaviour.
*
* @author Martin Riedel
*
*/
class PortSpinner extends JSpinner {
private static final long serialVersionUID = 1231652057161765899L;
/*
* The maximum number of digits in a port. Since the highest legal port number is 65535, this
* constant shouldn't need changing.
*/
public static final int maxDigits = 5;
/*
* The underlying Editor
*/
private final PortSpinnerEditor editor;
/*
* Initial port (later to be overwritten by the user's last selected port, if present (loaded from the user's settings file)
*/
private static final int initialPort = Settings.LOCAL_PORT;
/*
* The minimum valid port (usually 1 or 1024)
*/
private static final int minPort = Settings.getPreferences().getInt("local_port_min", Settings.LOCAL_PORT_MIN);
/*
* The maximum valid port (usually 65535)
*/
private static final int maxPort = Settings.getPreferences().getInt("local_port_max", Settings.LOCAL_PORT_MAX);
/*
* The foreground (text) color of the text field at startup
*/
private final Color defaultForeground;
/**
* Constructs a new PortSpinner
*
*/
public PortSpinner() {
super(new SpinnerNumberModel(initialPort,
minPort,
maxPort,
1));
if (!(getEditor() instanceof NumberEditor))
throw new LogicError("PortSpinner Editor is not a NumberEditor");
this.editor = new PortSpinnerEditor();
setEditor(this.editor);
this.defaultForeground = this.editor.textField.getForeground();
}
/**
* Getter for {@code defaultForeground}
*
* @return PortSpinner's default foreground color
*/
protected final Color getDefaultForeground() {
return this.defaultForeground;
}
/**
* A modified JSpinner.NumberEditor that gracefully handles changes in the SpinnerModel without
* resetting the cursor position.
*
* @author Martin Riedel
*
*/
private class PortSpinnerEditor extends NumberEditor {
private static final long serialVersionUID = 371957247504918562L;
/*
* The underlying JFormattedTextField
*/
public final JFormattedTextField textField;
/*
* The underlying Document
*/
public final AbstractDocument document;
/**
* Constructs a PortSpinnereditor instance.
*
*/
public PortSpinnerEditor() {
// Initialize this NumberEditor.
// Secound parameter is the number format as described for the DecimalFormat class.
// 0 represents an integer value with no grouping
super(PortSpinner.this, "0");
this.textField = getTextField();
if (!(this.textField.getDocument() instanceof AbstractDocument))
throw new LogicError("PortSpinner JFormattedTextField Document is not an AbstractDocument");
this.document = (AbstractDocument) this.textField.getDocument();
this.textField.setHorizontalAlignment(SwingConstants.LEFT);
// limit the text editor to a maximum of 5 columns
//textField.setColumns(maxDigits);
// when focus is gained through a mouse click, set the cursor to where the click occurred
this.textField.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(final MouseEvent e) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
PortSpinnerEditor.this.textField.setCaretPosition(PortSpinnerEditor.this.textField.viewToModel(e.getPoint()));
}
});
}
});
// add the DocumentListener that will commit any changes to the SpinnerModel immediately
this.document.addDocumentListener(new PortSpinnerDocumentListener());
}
/**
* {@inheritDoc}
*/
@Override
public void stateChanged(final ChangeEvent e) {
// update the text field accordingly
this.textField.setText(getModel().getValue().toString());
}
/**
* Invoked when a valid number was inserted/selected (be it by the user or by the application)
*
*/
protected void onValidEdit() {
// check if the edit is actually invalid by checking for non-digits in the text field
// this is a hacky but convenient solution to the problem that it is hard to combine
// the following three aspects of the desired spinner behaviour:
// - there should be a minimum and a maximum value at which the respective buttons turn grey and the respective up or down button stops working
// - it should be possible to enter numbers exceeding these extremes, but it should be pointed out to the users that his choice is invalid
// - it should not be possible to insert any non-digits into the text field, or at least the presence of non-digits
// should be identified and pointed out to the user
// Since DocumentFilters just don't work on JSpinners for some mystical, undocumented reason (their event handlers just
// get invoked) and for another, similarly mystical and undocumented reason, a NumberEditor formatted with a
// DecimalFormat doesn't raise a ParseException when it encounters letters instead of digits, this seems like the
// easiest solution.
for (char c: this.textField.getText().toCharArray()) {
if (!(Character.isDigit(c))) {
onInvalidEdit();
return;
}
}
this.textField.setForeground(PortSpinner.this.getDefaultForeground());
}
/**
* Invoked when an invalid number was inserted/selected (be it by the user or by the application)
*
*/
protected void onInvalidEdit() {
this.textField.setForeground(new Color(255, 0, 0, 255));
}
/**
* A DocumentListener that commits all changes made to the Document (in this case the spinner's text field)
* immediately, so that the SpinnerModel reflects the text field's state at all times (provided that the value
* in the text field is valid), which should be much more intuitive than the default JSpinner behaviour.
*
* @author Martin Riedel
*
*/
private class PortSpinnerDocumentListener implements DocumentListener {
/**
* Constructs a new instance
*
*/
public PortSpinnerDocumentListener() {
// do nothing, just allow instantiation
}
/**
* {@inheritDoc}
*/
@Override
public void removeUpdate(DocumentEvent e) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
try {
commitEdit();
onValidEdit();
} catch (ParseException e1) {
onInvalidEdit();
}
}
});
}
/**
* {@inheritDoc}
*/
@Override
public void insertUpdate(DocumentEvent e) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
try {
commitEdit();
onValidEdit();
} catch (ParseException e1) {
onInvalidEdit();
}
}
});
}
/**
* {@inheritDoc}
*/
@Override
public void changedUpdate(DocumentEvent e) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
try {
commitEdit();
onValidEdit();
} catch (ParseException e1) {
onInvalidEdit();
}
}
});
}
}
}
}