0

I have a JSpinner with a NumberModel. The Spinner's text field allows arbitrary inputs but only accepts numerical input on commitEdit. This means, if I enter a number followed by any alphabetic characters and press enter then the formatter tries to parse the input and eventually cuts off the garbage input: "2adsklfja" --> "2"

I'd like to be notified if there was garbage input before it tries to parse the entire input. Is there any event that provides this information?

UPDATE: I accept some combinations of numeric and alphabetic inputs. The spinner stores measuring values. If user enters "23 inch" then I detect substring inch and convert the numeric value accordingly. My target is to display an error alert if the measuring unit string couldn't be detected or is unknown.

UPDATE (incomplete solution): This solution helps for user inputs in the form: number text

I added action listener to the spinner's text field and a change listener to the spinner. Both listeners invoke code that tries to parse a potential measuring unit string within the text field. If this isn't successfull the current input may be either a numeric input only, or has an unknown trailing alphabetic string. We need to detect the second case by checking the current input string with a regular expression: If it's entirely numerical then it's no invalid input. We also have to consider decimal delimiter character and thousands delimiter characters:

final DefaultFormatterFactory formatFact = (DefaultFormatterFactory)spinnerTextField.getFormatterFactory();
final NumberFormatter formatter = (NumberFormatter)formatFact.getDefaultFormatter();
final String currentValue = spinnerTextField.getText().trim();

// detecting known measuring unit strings
...

// catch invalid strings
if (!unitFound) {
  final char decSeparator = ((DecimalFormat)formatter.getFormat()).getDecimalFormatSymbols().getDecimalSeparator();
  final char thousandsSeparator = ((DecimalFormat)formatter.getFormat()).getDecimalFormatSymbols()
    .getGroupingSeparator();
  final boolean numberOnly = currentValue.matches("[\\d\\Q" + decSeparator + thousandsSeparator + "\\E]+");
  System.err.println("Invalid=" + !numberOnly + (!numberOnly ? ": " + currentValue : ""));
}
PAX
  • 1,056
  • 15
  • 33
  • 1
    You could using an `InputVerifier`, take a look at [Validating Input](http://docs.oracle.com/javase/tutorial/uiswing/misc/focus.html#inputVerification) – MadProgrammer Feb 05 '14 at 08:42
  • @MadProgrammer I've been playing with the `InputVerifier` (a spinner gets one by default) for a while now.. It is trickier than it seems! – Andrew Thompson Feb 05 '14 at 08:55
  • 1
    listen to the _editValid_ property of the textfield as [outlined in a recent QA](http://stackoverflow.com/a/20906048/203657) – kleopatra Feb 05 '14 at 09:27
  • 1
    @MadProgrammer - agree with Andrew: InputVerifier and JFormattedTextField don't play nicely with each other – kleopatra Feb 05 '14 at 09:28
  • @AndrewThompson That's why I made it a comment, keep running around in my over `JSpinner` and `JFormattedField` having issues with a range of "features" @kleopatra – MadProgrammer Feb 05 '14 at 09:56
  • Obviously, ``InputVerifier`` is getting active when the text field loses its focus. It doesn't catch pressing of enter. – PAX Feb 05 '14 at 09:57
  • @kleopatra Thanks for that hint! This approach is almost acceptable. But This property doesn't change if I enter ``"23alskdjflj"``. – PAX Feb 05 '14 at 12:31
  • it should change the moment you type the "a", might be that you have to set some property so that the textField tries to commit on each typed key (probably done somewhere in the code of the other question) – kleopatra Feb 05 '14 at 12:45
  • don't mix the number and the unit into one input element - they are different entities (_physikalische Größe = Maßzahl * Einheit_, as you know :) and should be kept separate for usability. – kleopatra Feb 05 '14 at 13:43
  • @kleopatra The spinner doesn't display the current unit. For this purpose, there's a separate combo box. The special input behavior is part of the requirement. User can select "millimeters" in combo box and insert "23 inch" into spinner which converts it into "millimeters" correctly. – PAX Feb 05 '14 at 13:56
  • 1
    hmm ... sounds fishy. Anyway, then the way out is a custom formatter that detects the valid/invalid units (or parts of it) – kleopatra Feb 05 '14 at 14:09

1 Answers1

0

You said:

The Spinner's text field allows arbitrary inputs but only accepts numerical input on commitEdit.

If you just want your commitEdit() call to accept something like "125 inch", then try this approach:

  1. Supply the JSpinner with a DefaultEditor.
  2. Obtain the DefaultEditor's JFormattedTextField.
  3. Make it editable.
  4. Supply it with a custom (simple) AbstractFormatterFactory.
  5. Your custom AbstractFormatterFactory makes custom (simple) AbstractFormatters.
  6. These AbstractFormatters handle the parsing of the user's input string to a value, and back, so you can then check for any parsing errors and throw ParseException if needed.

Follows sample code:

import java.awt.GridLayout;  
import java.text.ParseException;  
import java.util.HashMap;  
import java.util.Objects;  
import javax.swing.JButton;  
import javax.swing.JFormattedTextField;  
import javax.swing.JFormattedTextField.AbstractFormatter;  
import javax.swing.JFormattedTextField.AbstractFormatterFactory;  
import javax.swing.JFrame;  
import javax.swing.JOptionPane;  
import javax.swing.JPanel;  
import javax.swing.JSpinner;  
import javax.swing.JSpinner.DefaultEditor;  
import javax.swing.SpinnerNumberModel;  

public final class CommitSpinner extends JPanel {  
    private String currentUnit;

    /**
     * Your formatter converts the text to integer by ignoring any measuring units,
     * and then converts the integer back to string by appending the last measuring unit:
     */
    private final class MyFormatter extends AbstractFormatter {
        @Override
        public Object stringToValue(final String text) throws ParseException {
            currentUnit = parseUnitPart(text);
            return parseIntegerPart(text);
        }

        @Override
        public String valueToString(final Object value) throws ParseException {
            return Objects.toString(value) + ' '  + currentUnit;
        }
    }

    private final class MyFormatterFactory extends AbstractFormatterFactory {
        private final HashMap<JFormattedTextField, AbstractFormatter> formatters;

        private MyFormatterFactory() {
            formatters = new HashMap<>();
        }

        /**
         * Because this method is a 'getter', I implemented the factory with a HashMap.
         * If it was a 'createFormatter' method for example, you could simply return a new
         * instance of MyFormatter.
         * @param tf the formatted text field to obtain its formatter.
         * @return the formatter for the given formatted text field.
         */
        @Override
        public AbstractFormatter getFormatter(final JFormattedTextField tf) {
            if (!formatters.containsKey(tf))
                formatters.put(tf, new MyFormatter());
            return formatters.get(tf);
        }
    }

    private CommitSpinner() {
        super(new GridLayout(0, 1));

        currentUnit = "inch"; //Let's say the first value of the spinner is measured in inches.

        final JSpinner spin = new JSpinner(new SpinnerNumberModel(0, 0, 10000, 1)); //Your values here. Let's say for now this is a spinner for integers.

        final DefaultEditor editor = new DefaultEditor(spin); //We need a DefaultEditor (to be able to obtain the JFormattedTextField and customize it).

        final JFormattedTextField field = editor.getTextField();
        field.setFocusLostBehavior(JFormattedTextField.COMMIT); //Could be "PERSIST" also, but it shall not be "COMMIT_OR_REVERT" (which is the default).
        field.setFormatterFactory(new MyFormatterFactory());
        field.setEditable(true); //Allow user input (obvious reasons).

        spin.setEditor(editor);

        //The commitButton button will "commitEdit()", and the ParseException exception part works as expected!
        final JButton commitButton = new JButton("Check input");
        commitButton.addActionListener(e -> {
            try {
                spin.commitEdit();
                JOptionPane.showMessageDialog(null, currentUnit + " = " + spin.getValue());
            }
            catch (final ParseException pe) {
                JOptionPane.showMessageDialog(null, pe.toString(), "Illegal input!", JOptionPane.ERROR_MESSAGE);
            }
        });

        add(spin);
        add(commitButton);
    }

    public static void main(final String[] args) {
        final JFrame frame = new JFrame("CommitSpinner demo");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.getContentPane().add(new CommitSpinner());
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }

    //Let's parse user input... What could go wrong?... xD
    private static int parseIntegerPart(final String text) throws ParseException {
        if (text == null)
            throw new ParseException("Text is null.", 0);
        if (text.trim().isEmpty())
            throw new ParseException("Text is empty.", 0);
        final String[] args = text.split(" ");
        if (args.length != 2)
            throw new ParseException("Text has invalid number of arguments (required 2, found " + args.length + ").", 0);
        final String unit = args[1].trim();
        if (!unit.equalsIgnoreCase("inch")
            && !unit.equalsIgnoreCase("cm"))
            throw new ParseException("Nor 'inch', nor 'cm' detected.", 0);
        try {
            return Integer.valueOf(args[0].trim());
        }
        catch (final NumberFormatException nfe) {
            throw new ParseException(args[0] + " is not a valid integer value.", 0);
        }
    }

    //Let's parse user input... What could go wrong?... xD
    private static String parseUnitPart(final String text) throws ParseException {
        if (text == null)
            throw new ParseException("Text is null.", 0);
        if (text.trim().isEmpty())
            throw new ParseException("Text is empty.", 0);
        final String[] args = text.split(" ");
        if (args.length != 2)
            throw new ParseException("Text has invalid number of arguments (required 2, found " + args.length + ").", 0);
        final String unit = args[1].trim().toLowerCase();
        if (!unit.equals("inch") && !unit.equals("cm"))
            throw new ParseException("Nor 'inch', nor 'cm' detected.", 0);
        return unit;
    }
}  

You also said:

I'd like to be notified if there was garbage input before it tries to parse the entire input.

A custom DocumentFilter in the JFormattedTextField will do the trick.

In AbstractFormatter there is a method called getDocumentFilter(). If you see the code, this method is called to add a DocumentFilter to the JFormattedTextField.

DocumentFilters' methods are called while the user is typing, i.e. you can parse the user's input before he "commits" it.

The default implementation returns null, which means no DocumentFilter will be added.

So I just only overrided this method to implement my custom (simple) DocumentFilter which in turn checks the user input while the user is typing it...

If the user's input is parsed OK then I just set the foreground color of a label to green. If not, then red. But this is just to demonstrate where you may handle such events.

Follows sample code:

import java.awt.Color;
import java.awt.GridLayout;
import java.text.ParseException;
import java.util.HashMap;
import java.util.Objects;
import javax.swing.JButton;
import javax.swing.JFormattedTextField;
import javax.swing.JFormattedTextField.AbstractFormatter;
import javax.swing.JFormattedTextField.AbstractFormatterFactory;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JSpinner.DefaultEditor;
import javax.swing.SpinnerNumberModel;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DocumentFilter;

public final class RealTimeSpinner extends JPanel {
    private String currentUnit;
    private final JLabel stateLabel;

    private final class MyFormatter extends AbstractFormatter {
        @Override
        public Object stringToValue(final String text) throws ParseException {
            currentUnit = parseUnitPart(text);
            return parseIntegerPart(text);
        }

        @Override
        public String valueToString(final Object value) throws ParseException {
            return Objects.toString(value) + ' '  + currentUnit;
        }

        @Override
        protected DocumentFilter getDocumentFilter() {
            return new DocumentFilter() {
                private void after(final FilterBypass fb) throws BadLocationException {
                    try {
                        //Obtain the user's input text so far:
                        final String theWholeNewText = fb.getDocument().getText(0, fb.getDocument().getLength());

                        //Try parse the user's input so far:
                        parseUnitPart(theWholeNewText);
                        parseIntegerPart(theWholeNewText);

                        //If the parsing succeeds, then set the foreground color to GREEN:
                        stateLabel.setForeground(Color.GREEN.darker());
                    }
                    catch (final ParseException pe) {

                        //If the parsing fails, then set the foreground color to RED:
                        stateLabel.setForeground(Color.RED.darker());
                    }
                }

                @Override
                public void remove(final FilterBypass fb, final int offset, final int length) throws BadLocationException {
                    super.remove(fb, offset, length);
                    after(fb);
                }

                @Override
                public void insertString(final FilterBypass fb, final int offset, final String string, final AttributeSet attr) throws BadLocationException {
                    super.insertString(fb, offset, string, attr);
                    after(fb);
                }

                @Override
                public void replace(final FilterBypass fb, final int offset, final int length, final String text, final AttributeSet attrs) throws BadLocationException {
                    super.replace(fb, offset, length, text, attrs);
                    after(fb);
                }
            };
        }
    }

    private final class MyFormatterFactory extends AbstractFormatterFactory {
        private final HashMap<JFormattedTextField, AbstractFormatter> formatters;

        private MyFormatterFactory() {
            formatters = new HashMap<>();
        }

        /**
         * Because this method is a 'getter', I implemented the factory with a HashMap.
         * If it was a 'createFormatter' method for example, you could simply return a new
         * instance of MyFormatter.
         * @param tf the formatted text field to obtain its formatter.
         * @return the formatter for the given formatted text field.
         */
        @Override
        public AbstractFormatter getFormatter(final JFormattedTextField tf) {
            if (!formatters.containsKey(tf))
                formatters.put(tf, new MyFormatter());
            return formatters.get(tf);
        }
    }

    private RealTimeSpinner() {
        super(new GridLayout(0, 1));

        currentUnit = "inch"; //Let's say the first value of the spinner is measured in inches.

        final JSpinner spin = new JSpinner(new SpinnerNumberModel(0, 0, 10000, 1)); //Your values here. Let's say for now this is a spinner for integers.

        final DefaultEditor editor = new DefaultEditor(spin); //We need a DefaultEditor (to be able to obtain the JFormattedTextField and customize it).

        final JFormattedTextField field = editor.getTextField();
        field.setFocusLostBehavior(JFormattedTextField.COMMIT); //Could be "PERSIST" also, but it shall not be "COMMIT_OR_REVERT" (which is the default).
        field.setFormatterFactory(new MyFormatterFactory());
        field.setEditable(true); //Allow user input (obvious reasons).

        spin.setEditor(editor);

        final JButton commitButton = new JButton("Check input");
        commitButton.addActionListener(e -> {
            try {
                spin.commitEdit();
                JOptionPane.showMessageDialog(null, currentUnit + " = " + spin.getValue());
            }
            catch (final ParseException pe) {
                JOptionPane.showMessageDialog(null, pe.toString(), "Illegal input!", JOptionPane.ERROR_MESSAGE);
            }
        });

        stateLabel = new JLabel("This is the color of the state of the input.", JLabel.CENTER);
        stateLabel.setForeground(Color.GREEN.darker());

        add(spin);
        add(commitButton);
        add(stateLabel);
    }

    public static void main(final String[] args) {
        final JFrame frame = new JFrame("CommitSpinner demo");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.getContentPane().add(new RealTimeSpinner());
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }

    //Let's parse user input... What could go wrong?... xD
    private static int parseIntegerPart(final String text) throws ParseException {
        if (text == null)
            throw new ParseException("Text is null.", 0);
        if (text.trim().isEmpty())
            throw new ParseException("Text is empty.", 0);
        final String[] args = text.split(" ");
        if (args.length != 2)
            throw new ParseException("Text has invalid number of arguments (required 2, found " + args.length + ").", 0);
        final String unit = args[1].trim();
        if (!unit.equalsIgnoreCase("inch")
            && !unit.equalsIgnoreCase("cm"))
            throw new ParseException("Nor 'inch', nor 'cm' detected.", 0);
        try {
            return Integer.valueOf(args[0].trim());
        }
        catch (final NumberFormatException nfe) {
            throw new ParseException(args[0] + " is not a valid integer value.", 0);
        }
    }

    //Let's parse user input... What could go wrong?... xD
    private static String parseUnitPart(final String text) throws ParseException {
        if (text == null)
            throw new ParseException("Text is null.", 0);
        if (text.trim().isEmpty())
            throw new ParseException("Text is empty.", 0);
        final String[] args = text.split(" ");
        if (args.length != 2)
            throw new ParseException("Text has invalid number of arguments (required 2, found " + args.length + ").", 0);
        final String unit = args[1].trim().toLowerCase();
        if (!unit.equals("inch") && !unit.equals("cm"))
            throw new ParseException("Nor 'inch', nor 'cm' detected.", 0);
        return unit;
    }
}
gthanop
  • 3,035
  • 2
  • 10
  • 27