001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2024
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas/
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see https://www.gnu.org/licenses/.
027 */
028
029package edu.wisc.ssec.mcidasv.ui;
030
031import static java.text.DateFormat.getDateInstance;
032import static javax.swing.UIManager.getColor;
033
034import com.toedter.calendar.DateUtil;
035import com.toedter.calendar.IDateEditor;
036
037import org.joda.time.LocalDate;
038import org.slf4j.Logger;
039import org.slf4j.LoggerFactory;
040
041import java.awt.Color;
042import java.awt.Dimension;
043import java.awt.event.ActionEvent;
044import java.awt.event.ActionListener;
045import java.awt.event.FocusEvent;
046import java.awt.event.FocusListener;
047
048import javax.swing.JComponent;
049import javax.swing.JFormattedTextField;
050import javax.swing.JLabel;
051import javax.swing.JTextField;
052import javax.swing.event.CaretEvent;
053import javax.swing.event.CaretListener;
054import javax.swing.text.MaskFormatter;
055
056import java.text.DateFormat;
057import java.text.ParseException;
058import java.text.SimpleDateFormat;
059import java.util.Calendar;
060import java.util.Date;
061import java.util.Locale;
062import java.util.regex.Pattern;
063
064/**
065 * This class is just a {@link com.toedter.calendar.JTextFieldDateEditor} that
066 * allows the user to enter either the day within (current) year or a
067 * McIDAS-X style {@literal "julian day"} ({@code YYYYDDD} or {@code YYDDD}),
068 * in addition to the formatting allowed by {@code JTextFieldDateEditor}.
069 */
070public class JCalendarDateEditor extends JFormattedTextField implements
071    IDateEditor, CaretListener, FocusListener, ActionListener
072{
073
074    private static final long serialVersionUID = 1L;
075
076    /** Match day of year. */
077    private static final Pattern dayOnly = Pattern.compile("\\d{1,3}");
078
079    /** Match {@code YYYYDDD}. */
080    private static final Pattern yearDay = Pattern.compile("\\d{7}");
081
082    /** Match {@code YYDDD} dates. */
083    private static final Pattern badYearDay = Pattern.compile("\\d{5}");
084
085    private static final Logger logger =
086        LoggerFactory.getLogger(JCalendarDateEditor.class);
087
088    protected Date date;
089
090    protected SimpleDateFormat dateFormatter;
091
092    /** Parse {@code DDD} dates (even if they are one or two digits). */
093    private final SimpleDateFormat dayOfYear;
094
095    /** Parse {@code YYYYDDD} dates. */
096    private final SimpleDateFormat yearAndDay;
097
098    /** Parse {@code YYDDD} dates. */
099    private final SimpleDateFormat badYearAndDay;
100
101    protected MaskFormatter maskFormatter;
102
103    protected String datePattern;
104
105    protected String maskPattern;
106
107    protected char placeholder;
108
109    protected Color darkGreen;
110
111    protected DateUtil dateUtil;
112
113    private boolean isMaskVisible;
114
115    private boolean ignoreDatePatternChange;
116
117    private int hours;
118
119    private int minutes;
120
121    private int seconds;
122
123    private int millis;
124
125    private Calendar calendar;
126
127    public JCalendarDateEditor() {
128        this(false, null, null, ' ');
129    }
130
131    public JCalendarDateEditor(String datePattern, String maskPattern,
132                               char placeholder)
133    {
134        this(true, datePattern, maskPattern, placeholder);
135    }
136
137    public JCalendarDateEditor(boolean showMask, String datePattern,
138                               String maskPattern, char placeholder)
139    {
140        dateFormatter = (SimpleDateFormat)getDateInstance(DateFormat.MEDIUM);
141        dayOfYear = new SimpleDateFormat("DDD");
142        yearAndDay = new SimpleDateFormat("yyyyDDD");
143        badYearAndDay = new SimpleDateFormat("yyDDD");
144        dateFormatter.setLenient(false);
145        dayOfYear.setLenient(false);
146        yearAndDay.setLenient(false);
147
148        setDateFormatString(datePattern);
149        if (datePattern != null) {
150            ignoreDatePatternChange = true;
151        }
152
153        this.placeholder = placeholder;
154
155        if (maskPattern == null) {
156            this.maskPattern = createMaskFromDatePattern(this.datePattern);
157        } else {
158            this.maskPattern = maskPattern;
159        }
160
161        setToolTipText(this.datePattern);
162        setMaskVisible(showMask);
163
164        addCaretListener(this);
165        addFocusListener(this);
166        addActionListener(this);
167        darkGreen = new Color(0, 150, 0);
168
169        calendar = Calendar.getInstance();
170
171        dateUtil = new DateUtil();
172    }
173
174    /*
175     * (non-Javadoc)
176     * 
177     * @see com.toedter.calendar.IDateEditor#getDate()
178     */
179    @Override public Date getDate() {
180        try {
181            calendar.setTime(dateFormatter.parse(getText()));
182            calendar.set(Calendar.HOUR_OF_DAY, hours);
183            calendar.set(Calendar.MINUTE, minutes);
184            calendar.set(Calendar.SECOND, seconds);
185            calendar.set(Calendar.MILLISECOND, millis);
186            date = calendar.getTime();
187        } catch (ParseException e) {
188            date = null;
189        }
190        return date;
191    }
192
193    /*
194     * (non-Javadoc)
195     * 
196     * @see com.toedter.calendar.IDateEditor#setDate(java.util.Date)
197     */
198    @Override public void setDate(Date date) {
199        setDate(date, true);
200    }
201
202    /**
203     * Sets the date.
204     *
205     * @param date the date
206     * @param firePropertyChange true, if the date property should be fired.
207     */
208    protected void setDate(Date date, boolean firePropertyChange) {
209        Date oldDate = this.date;
210        this.date = date;
211
212        if (date == null) {
213            setText("");
214        } else {
215            calendar.setTime(date);
216            hours = calendar.get(Calendar.HOUR_OF_DAY);
217            minutes = calendar.get(Calendar.MINUTE);
218            seconds = calendar.get(Calendar.SECOND);
219            millis = calendar.get(Calendar.MILLISECOND);
220
221            String formattedDate = dateFormatter.format(date);
222            try {
223                setText(formattedDate);
224            } catch (RuntimeException e) {
225                logger.debug("could not set text: {}", e);
226            }
227        }
228        if ((date != null) && dateUtil.checkDate(date)) {
229            setForeground(Color.BLACK);
230        }
231
232        if (firePropertyChange) {
233            firePropertyChange("date", oldDate, date);
234        }
235    }
236
237    /*
238     * (non-Javadoc)
239     * 
240     * @see com.toedter.calendar.IDateEditor#setDateFormatString(java.lang.String)
241     */
242    @Override public void setDateFormatString(String dateFormatString) {
243        if (!ignoreDatePatternChange) {
244            try {
245                dateFormatter.applyPattern(dateFormatString);
246            } catch (RuntimeException e) {
247                dateFormatter =
248                    (SimpleDateFormat)getDateInstance(DateFormat.MEDIUM);
249                dateFormatter.setLenient(false);
250            }
251            this.datePattern = dateFormatter.toPattern();
252            setToolTipText(this.datePattern);
253            setDate(date, false);
254        }
255    }
256
257    /*
258     * (non-Javadoc)
259     * 
260     * @see com.toedter.calendar.IDateEditor#getDateFormatString()
261     */
262    @Override public String getDateFormatString() {
263        return datePattern;
264    }
265
266    /*
267     * (non-Javadoc)
268     * 
269     * @see com.toedter.calendar.IDateEditor#getUiComponent()
270     */
271    @Override public JComponent getUiComponent() {
272        return this;
273    }
274
275    private Date attemptParsing(String text) {
276        Date result = null;
277        try {
278            if (dayOnly.matcher(text).matches()) {
279                String full = LocalDate.now().getYear() + text;
280                result = yearAndDay.parse(full);
281            } else if (yearDay.matcher(text).matches()) {
282                result = yearAndDay.parse(text);
283            } else if (badYearDay.matcher(text).matches()) {
284                result = badYearAndDay.parse(text);
285            } else {
286                result = dateFormatter.parse(text);
287            }
288        } catch (Exception e) {
289            logger.trace("failed to parse '{}'", text);
290        }
291        return result;
292    }
293
294    /**
295     * After any user input, the value of the textfield is proofed. Depending on
296     * being a valid date, the value is colored green or red.
297     *
298     * @param event Caret event.
299     */
300    @Override public void caretUpdate(CaretEvent event) {
301        String text = getText().trim();
302        String emptyMask = maskPattern.replace('#', placeholder);
303
304        if (text.isEmpty() || text.equals(emptyMask)) {
305            setForeground(Color.BLACK);
306            return;
307        }
308
309        Date parsed = attemptParsing(this.getText());
310        if ((parsed != null) && dateUtil.checkDate(parsed)) {
311            this.setForeground(this.darkGreen);
312        } else {
313            this.setForeground(Color.RED);
314        }
315    }
316
317    /*
318     * (non-Javadoc)
319     * 
320     * @see java.awt.event.FocusListener#focusLost(java.awt.event.FocusEvent)
321     */
322    @Override public void focusLost(FocusEvent focusEvent) {
323        checkText();
324    }
325
326    private void checkText() {
327        Date parsedDate = attemptParsing(this.getText());
328        if (parsedDate != null) {
329            this.setDate(parsedDate, true);
330        }
331    }
332
333    /*
334     * (non-Javadoc)
335     * 
336     * @see java.awt.event.FocusListener#focusGained(java.awt.event.FocusEvent)
337     */
338    @Override public void focusGained(FocusEvent e) {
339    }
340
341    /*
342     * (non-Javadoc)
343     * 
344     * @see java.awt.Component#setLocale(java.util.Locale)
345     */
346    @Override public void setLocale(Locale locale) {
347        if (!locale.equals(getLocale()) || ignoreDatePatternChange) {
348            super.setLocale(locale);
349            dateFormatter =
350                (SimpleDateFormat) getDateInstance(DateFormat.MEDIUM, locale);
351            setToolTipText(dateFormatter.toPattern());
352            setDate(date, false);
353            doLayout();
354        }
355    }
356
357    /**
358     * Creates a mask from a date pattern. This is a very simple (and
359     * incomplete) implementation thet works only with numbers. A date pattern
360     * of {@literal "MM/dd/yy"} will result in the mask "##/##/##". Probably
361     * you want to override this method if it does not fit your needs.
362     *
363     * @param datePattern Date pattern.
364     * @return the mask
365     */
366    public String createMaskFromDatePattern(String datePattern) {
367        String symbols = "GyMdkHmsSEDFwWahKzZ";
368        StringBuilder maskBuffer = new StringBuilder(datePattern.length() * 2);
369        for (int i = 0; i < datePattern.length(); i++) {
370            char ch = datePattern.charAt(i);
371            boolean symbolFound = false;
372            for (int n = 0; n < symbols.length(); n++) {
373                if (symbols.charAt(n) == ch) {
374                    maskBuffer.append('#');
375                    symbolFound = true;
376                    break;
377                }
378            }
379            if (!symbolFound) {
380                maskBuffer.append(ch);
381            }
382        }
383        return maskBuffer.toString();
384    }
385
386    /**
387     * Returns {@code true}, if the mask is visible.
388     *
389     * @return {@code true}, if the mask is visible.
390     */
391    public boolean isMaskVisible() {
392        return isMaskVisible;
393    }
394
395    /**
396     * Sets the mask visible.
397     *
398     * @param isMaskVisible Whether or not the mask should be visible.
399     */
400    public void setMaskVisible(boolean isMaskVisible) {
401        this.isMaskVisible = isMaskVisible;
402        if (isMaskVisible) {
403            if (maskFormatter == null) {
404                try {
405                    String mask = createMaskFromDatePattern(datePattern);
406                    maskFormatter = new MaskFormatter(mask);
407                    maskFormatter.setPlaceholderCharacter(this.placeholder);
408                    maskFormatter.install(this);
409                } catch (ParseException e) {
410                    logger.debug("parsing error: {}", e);
411                }
412            }
413        }
414    }
415
416    /**
417     * Returns the preferred size, enough to accommodate longest date someone could enter
418     */
419    
420    @Override public Dimension getPreferredSize() {
421        
422        // TJJ May 2018 
423        // This didn't seem to work in all cases. So let's set the preferred size
424        // to a dimension that will accommodate the longest date someone could enter
425        Dimension d = new JTextField("September 28, 1999").getPreferredSize();
426        return d;
427
428    }
429
430    /**
431     * Validates the typed date and sets it (only if it is valid).
432     */
433    @Override public void actionPerformed(ActionEvent e) {
434        checkText();
435    }
436
437    /**
438     * Enables and disabled the compoment. It also fixes the background bug
439     * 4991597 and sets the background explicitely to a
440     * TextField.inactiveBackground.
441     */
442    @Override public void setEnabled(boolean b) {
443        super.setEnabled(b);
444        if (!b) {
445            super.setBackground(getColor("TextField.inactiveBackground"));
446        }
447    }
448
449    /*
450     * (non-Javadoc)
451     * 
452     * @see com.toedter.calendar.IDateEditor#getMaxSelectableDate()
453     */
454    @Override public Date getMaxSelectableDate() {
455        return dateUtil.getMaxSelectableDate();
456    }
457
458    /*
459     * (non-Javadoc)
460     * 
461     * @see com.toedter.calendar.IDateEditor#getMinSelectableDate()
462     */
463    @Override public Date getMinSelectableDate() {
464        return dateUtil.getMinSelectableDate();
465    }
466
467    /*
468     * (non-Javadoc)
469     * 
470     * @see com.toedter.calendar.IDateEditor#setMaxSelectableDate(java.util.Date)
471     */
472    @Override public void setMaxSelectableDate(Date max) {
473        dateUtil.setMaxSelectableDate(max);
474        checkText();
475    }
476
477    /*
478     * (non-Javadoc)
479     * 
480     * @see com.toedter.calendar.IDateEditor#setMinSelectableDate(java.util.Date)
481     */
482    @Override public void setMinSelectableDate(Date min) {
483        dateUtil.setMinSelectableDate(min);
484        checkText();
485    }
486
487    /*
488     * (non-Javadoc)
489     * 
490     * @see com.toedter.calendar.IDateEditor#setSelectableDateRange(java.util.Date,java.util.Date)
491     */
492    @Override public void setSelectableDateRange(Date min, Date max) {
493        dateUtil.setSelectableDateRange(min, max);
494        checkText();
495    }
496}