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}