001package jmri.util;
002
003import java.awt.Component;
004import java.util.function.Function;
005import java.util.function.Predicate;
006
007import javax.swing.*;
008import javax.swing.event.DocumentEvent;
009import javax.swing.event.DocumentListener;
010
011/**
012 * A helper Panel for input-validating input boxes. It converts and validates the
013 * text input, disabling {@link #confirmUI} component (usually a button) when
014 * the input is not valid.
015 * 
016 * @author Svata Dedic Copyright (c) 2019
017 */
018@SuppressWarnings("javadoc")
019final class ValidatingInputPane<T> extends javax.swing.JPanel  {
020    private final Function<String, T> convertor;
021    private final DocumentListener l = new DocumentListener() {
022        @Override
023        public void insertUpdate(DocumentEvent e) {
024            validateInput();
025        }
026
027        @Override
028        public void removeUpdate(DocumentEvent e) {
029            validateInput();
030        }
031
032        @Override
033        public void changedUpdate(DocumentEvent e) {
034        }
035    };
036
037    /**
038     * Callback that validates the input after conversion.
039     */
040    private Predicate<T>    validator;
041
042    /**
043     * The confirmation component. The component is disabled when the
044     * input is rejected by converter or validator
045     */
046    private JComponent      confirmUI;
047    
048    /**
049     * Holds the last seen error. {@code null} for no error - valid input
050     */
051    private String lastError;
052    
053    /**
054     * Last custom exception. {@code null}, if no error or if
055     * the validator just rejected with no message.
056     */
057    private IllegalArgumentException customException;
058    
059    /**
060     * Creates new form ValidatingInputPane.
061     * @param convertor converts String to the desired data type.
062     */
063    public ValidatingInputPane(Function<String, T> convertor) {
064        initComponents();
065        errorMessage.setVisible(false);
066        this.convertor = convertor;
067    }
068    
069    /**
070     * Attaches a component used to confirm/proceed. The component will
071     * be disabled if the input is erroneous. The first validation will happen
072     * after this component appears on the screen. Typically, the OK button
073     * should be passed here.
074     * 
075     * @param confirm the "confirm" control.
076     * @return this instance.
077     */
078    ValidatingInputPane<T> attachConfirmUI(JComponent confirm) {
079        this.confirmUI = confirm;
080        return this;
081    }
082    
083    @Override
084    public void addNotify() {
085        super.addNotify();
086        inputText.getDocument().addDocumentListener(l);
087        SwingUtilities.invokeLater(this::validateInput);
088    }
089    
090    /**
091     * Configures a prompt message for the panel. The prompt message
092     * appears above the input line.
093     * @param msg message text
094     * @return this instance.
095     */
096    ValidatingInputPane<T> message(String msg) {
097        promtptMessage.setText(msg);
098        return this;
099    }
100    
101    /**
102     * Returns the exception from the most recent validation. Only exceptions
103     * from unsuccessful conversion or thrown by validator are returned. To check
104     * whether the input is valid, call {@link #hasError()}. If the validator 
105     * just rejects the input with no exception, this method returns {@code null}
106     * @return exception thrown by converter or validator.
107     */
108    IllegalArgumentException getException() {
109        return customException;
110    }
111    
112    /**
113     * Configures the validator. Validator is called to check the value after
114     * the String input is converted to the target type. The validator can either
115     * just return {@code false} to reject the value with a generic message, or
116     * throw a {@link IllegalArgumentException} subclass with a custom message.
117     * The message will be then displayed below the input line.
118     * 
119     * @param val validator instance, {@code null} to disable.
120     * @return this instance
121     */
122    ValidatingInputPane<T> validator(Predicate<T> val) {
123        this.validator = val;
124        return this;
125    }
126    
127    /**
128     * Determines if the input is erroneous.
129     * @return error status
130     */
131    boolean hasError() {
132        return lastError != null;
133    }
134    
135    /**
136     * Sets the input value, as text.
137     * @param text input text
138     */
139    void setText(String text) {
140        inputText.setText(text);
141    }
142    
143    /**
144     * Gets the input value, as text.
145     * @return the input text
146     */
147    String getText() {
148        return inputText.getText().trim();
149    }
150    
151    /**
152     * Gets the input value after conversion. May throw {@link IllegalArgumentException}
153     * if the conversion fails (text input cannot be converted to the target type).
154     * Returns {@code null} for empty (all whitespace) input.
155     * @return the entered value or {@code null} for empty input.
156     */
157    T getValue() {
158        String s = getText();
159        return s.isEmpty() ? null : convertor.apply(s);
160    }
161    
162    /**
163     * Gets the error message. Either a custom message from an exception
164     * thrown by converter or validator, or the default message for failed validation.
165     * Returns {@code null} for valid input.
166     * @return if input is invalid, returns the error message. If the input is valid, returns {@code null}.
167     */
168    String getErrorMessage() {
169        return lastError;
170    }
171    
172    private void validateInput() {
173        if (isVisible()) {
174            validateText(getText());
175        }
176    }
177    
178    private void clearErrors() {
179        if (confirmUI != null) {
180            confirmUI.setEnabled(true);
181        }
182        errorMessage.setText("");
183        errorMessage.setVisible(false);
184        customException = null;
185        lastError = null;
186    }
187    
188    /**
189     * Should be called from tests only
190     * @param text String to check for validation
191     */
192    void validateText(String text) {
193        String msg;
194        if (text.isEmpty()) {
195            clearErrors();
196            return;
197        }
198        try {
199            T value = convertor.apply(text);
200            if (validator == null || 
201                validator.test(value)) {
202                clearErrors();
203                return;
204            }
205            msg = Bundle.getMessage("InputDialogError");
206        } catch (IllegalArgumentException ex) {
207            msg = ex.getLocalizedMessage();
208            customException = ex;
209        }
210        lastError = msg;
211        errorMessage.setText(msg);
212        errorMessage.setVisible(true);
213        if (confirmUI != null) {
214            confirmUI.setEnabled(false);
215        }
216        Component c = SwingUtilities.getRoot(this);
217        if (c != null) {
218            c.invalidate();
219            if (c instanceof JDialog) {
220                ((JDialog)c).pack();
221            }
222        }
223    }
224
225    // only for testing
226    JTextField getTextField() {
227        return inputText;
228    }
229    
230    /**
231     * This method is called from within the constructor to initialize the form.
232     * WARNING: Do NOT modify this code. The content of this method is always
233     * regenerated by the Form Editor.
234     */
235    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
236    private void initComponents() {
237
238        jScrollPane2 = new javax.swing.JScrollPane();
239        jTextArea2 = new javax.swing.JTextArea();
240        promtptMessage = new javax.swing.JLabel();
241        inputText = new javax.swing.JTextField();
242        errorMessage = new javax.swing.JTextArea();
243
244        jTextArea2.setColumns(20);
245        jTextArea2.setRows(5);
246        jScrollPane2.setViewportView(jTextArea2);
247
248        promtptMessage.setText(" ");
249
250        errorMessage.setEditable(false);
251        errorMessage.setBackground(getBackground());
252        errorMessage.setColumns(20);
253        errorMessage.setForeground(java.awt.Color.red);
254        errorMessage.setRows(2);
255        errorMessage.setToolTipText("");
256        errorMessage.setAutoscrolls(false);
257        errorMessage.setBorder(null);
258        errorMessage.setFocusable(false);
259        errorMessage.setRequestFocusEnabled(false);
260        errorMessage.setVerifyInputWhenFocusTarget(false);
261
262        javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this);
263        this.setLayout(layout);
264        layout.setHorizontalGroup(
265            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
266            .addGroup(layout.createSequentialGroup()
267                .addContainerGap()
268                .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
269                    .addComponent(promtptMessage, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)
270                    .addComponent(errorMessage, javax.swing.GroupLayout.DEFAULT_SIZE, 253, Short.MAX_VALUE)
271                    .addComponent(inputText))
272                .addContainerGap())
273        );
274        layout.setVerticalGroup(
275            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
276            .addGroup(layout.createSequentialGroup()
277                .addContainerGap()
278                .addComponent(promtptMessage)
279                .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED)
280                .addComponent(inputText, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE)
281                .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED)
282                .addComponent(errorMessage, javax.swing.GroupLayout.PREFERRED_SIZE, 20, javax.swing.GroupLayout.PREFERRED_SIZE)
283                .addContainerGap(javax.swing.GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE))
284        );
285    }// </editor-fold>//GEN-END:initComponents
286
287
288    // Variables declaration - do not modify//GEN-BEGIN:variables
289    private javax.swing.JTextArea errorMessage;
290    private javax.swing.JTextField inputText;
291    private javax.swing.JScrollPane jScrollPane2;
292    private javax.swing.JTextArea jTextArea2;
293    private javax.swing.JLabel promtptMessage;
294    // End of variables declaration//GEN-END:variables
295}