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