001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2017
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 http://www.gnu.org/licenses.
027 */
028package edu.wisc.ssec.mcidasv.chooser;
029
030import static javax.swing.GroupLayout.DEFAULT_SIZE;
031import static javax.swing.GroupLayout.Alignment.BASELINE;
032import static javax.swing.GroupLayout.Alignment.LEADING;
033import static javax.swing.GroupLayout.Alignment.TRAILING;
034import static javax.swing.LayoutStyle.ComponentPlacement.RELATED;
035import static javax.swing.LayoutStyle.ComponentPlacement.UNRELATED;
036
037import static edu.wisc.ssec.mcidasv.McIDASV.getStaticMcv;
038
039import java.awt.Dimension;
040import java.awt.Insets;
041
042import java.beans.PropertyChangeListener;
043
044import java.io.File;
045import java.io.IOException;
046
047import java.nio.file.Files;
048import java.nio.file.Path;
049import java.nio.file.Paths;
050
051import java.util.ArrayList;
052import java.util.HashMap;
053import java.util.List;
054import java.util.Map;
055import java.util.Objects;
056
057import javax.swing.GroupLayout;
058import javax.swing.JButton;
059import javax.swing.JComboBox;
060import javax.swing.JComponent;
061import javax.swing.JFileChooser;
062import javax.swing.JLabel;
063import javax.swing.JPanel;
064import javax.swing.SwingUtilities;
065import javax.swing.event.AncestorEvent;
066import javax.swing.event.AncestorListener;
067import javax.swing.filechooser.FileFilter;
068
069import edu.wisc.ssec.mcidasv.McIDASV;
070import edu.wisc.ssec.mcidasv.util.pathwatcher.OnFileChangeListener;
071import org.bushe.swing.event.annotation.AnnotationProcessor;
072import org.w3c.dom.Element;
073
074import org.slf4j.Logger;
075import org.slf4j.LoggerFactory;
076
077import org.bushe.swing.event.annotation.EventTopicSubscriber;
078
079import ucar.unidata.idv.IntegratedDataViewer;
080import ucar.unidata.idv.chooser.IdvChooserManager;
081import ucar.unidata.util.FileManager;
082import ucar.unidata.util.GuiUtils;
083import ucar.unidata.util.Misc;
084import ucar.unidata.util.PatternFileFilter;
085import ucar.unidata.util.TwoFacedObject;
086import ucar.unidata.xml.XmlUtil;
087
088import edu.wisc.ssec.mcidasv.util.pathwatcher.DirectoryWatchService;
089
090import edu.wisc.ssec.mcidasv.Constants;
091import edu.wisc.ssec.mcidasv.util.McVGuiUtils;
092import edu.wisc.ssec.mcidasv.util.McVGuiUtils.Position;
093import edu.wisc.ssec.mcidasv.util.McVGuiUtils.TextColor;
094import edu.wisc.ssec.mcidasv.util.McVGuiUtils.Width;
095
096/**
097 * {@code FileChooser} is another {@literal "UI nicety"} extension. The main
098 * difference is that this class allows {@code choosers.xml} to specify a
099 * boolean attribute, {@code "selectdatasourceid"}. If disabled or not present,
100 * a {@code FileChooser} will behave exactly like a standard 
101 * {@link FileChooser}.
102 * 
103 * <p>If the attribute is present and enabled, the {@code FileChooser}'s 
104 * data source type will automatically select the 
105 * {@link ucar.unidata.data.DataSource} corresponding to the chooser's 
106 * {@code "datasourceid"} attribute.
107 */
108public class FileChooser extends ucar.unidata.idv.chooser.FileChooser
109    implements Constants, AncestorListener
110{
111    
112    /** Logging object. */
113    private static final Logger logger =
114        LoggerFactory.getLogger(FileChooser.class);
115        
116    /** 
117     * Chooser attribute that controls selecting the default data source.
118     * @see #selectDefaultDataSource
119     */
120    public static final String ATTR_SELECT_DSID = "selectdatasourceid";
121    
122    /** Default data source ID for this chooser. Defaults to {@code null}. */
123    private final String defaultDataSourceId;
124    
125    /** 
126     * Whether or not to select the data source corresponding to 
127     * {@link #defaultDataSourceId} within the {@link JComboBox} returned by
128     * {@link #getDataSourcesComponent()}. Defaults to {@code false}.
129     */
130    private final boolean selectDefaultDataSource;
131
132    /**
133     * If there is a default data source ID, get the combo box display value
134     */
135    private String defaultDataSourceName;
136    
137    /** Different subclasses can use the combobox of data source ids */
138    private JComboBox sourceComboBox;
139    
140    /** Get a handle on the actual file chooser. */
141    protected JFileChooser fileChooser;
142    
143    /** Panels that might need to be enabled/disabled. */
144    protected JPanel topPanel = new JPanel();
145    protected JPanel centerPanel = new JPanel();
146    protected JPanel bottomPanel = new JPanel();
147    
148    /**
149     * Boolean to tell if the load was initiated from the load button
150     * (as opposed to typing in a filename... we need to capture that)
151     */
152    protected Boolean buttonPressed = false;
153    
154    /** Get a handle on the IDV. */
155    protected IntegratedDataViewer idv = getIdv();
156
157    /** This is mostly used to preemptively null-out the listener. */
158    protected OnFileChangeListener watchListener;
159    
160    /**
161     * Value is controlled via {@link #ancestorAdded(AncestorEvent)} and
162     * {@link #ancestorRemoved(AncestorEvent)}
163     */
164    private boolean trulyVisible;
165    
166    /**
167     * Creates a {@code FileChooser} and bubbles up {@code mgr} and 
168     * {@code root} to {@link FileChooser}.
169     * 
170     * @param mgr Global IDV chooser manager.
171     * @param root XML representing this chooser.
172     */
173    public FileChooser(final IdvChooserManager mgr, final Element root) {
174        super(mgr, root);
175        
176        AnnotationProcessor.process(this);
177        
178        String id = XmlUtil.getAttribute(root, ATTR_DATASOURCEID, (String)null);
179        defaultDataSourceId = (id != null) ? id.toLowerCase() : id;
180        
181        selectDefaultDataSource =
182            XmlUtil.getAttribute(root, ATTR_SELECT_DSID, false);
183    }
184    
185    /**
186     * Label for {@link #getDataSourcesComponent()} selector.
187     *
188     * @return {@code String} to use as the label for data type selector.
189     */
190    protected String getDataSourcesLabel() {
191        return "Data Type:";
192    }
193    
194    /**
195     * Overridden so that McIDAS-V can attempt auto-selecting the default data
196     * source type.
197     */
198    @Override
199    protected JComboBox getDataSourcesComponent() {
200        sourceComboBox = getDataSourcesComponent(true);
201        if (selectDefaultDataSource && defaultDataSourceId != null) {
202            Map<String, Integer> ids = comboBoxContents(sourceComboBox);
203            if (ids.containsKey(defaultDataSourceId)) {
204                sourceComboBox.setSelectedIndex(ids.get(defaultDataSourceId));
205                defaultDataSourceName = sourceComboBox.getSelectedItem().toString();
206                sourceComboBox.setVisible(false);
207            }
208        }
209        return sourceComboBox;
210    }
211    
212    /**
213     * Maps data source IDs to their index within {@code box}. This method is 
214     * only applicable to {@link JComboBox}es created for {@link FileChooser}s.
215     * 
216     * @param box Combo box containing relevant data source IDs and indices. 
217     * 
218     * @return A mapping of data source IDs to their offset within {@code box}.
219     */
220    private static Map<String, Integer> comboBoxContents(final JComboBox box) {
221        assert box != null;
222        Map<String, Integer> map = new HashMap<>(box.getItemCount());
223        for (int i = 0; i < box.getItemCount(); i++) {
224            Object o = box.getItemAt(i);
225            if (!(o instanceof TwoFacedObject)) {
226                continue;
227            }
228            TwoFacedObject tfo = (TwoFacedObject)o;
229            map.put(TwoFacedObject.getIdString(tfo), i);
230        }
231        return map;
232    }
233    
234    /**
235     * If the dataSources combo box is non-null then
236     * return the data source id the user selected.
237     * Else, return null
238     *
239     * @return Data source id
240     */
241    protected String getDataSourceId() {
242        return getDataSourceId(sourceComboBox);
243    }
244    
245    /**
246     * Get the accessory component
247     *
248     * @return the component
249     */
250    protected JComponent getAccessory() {
251        return GuiUtils.left(
252            GuiUtils.inset(
253                FileManager.makeDirectoryHistoryComponent(
254                    fileChooser, false), new Insets(13, 0, 0, 0)));
255    }
256    
257    /**
258     * Override the base class method to catch the do load
259     */
260    public void doLoadInThread() {
261        selectFiles(fileChooser.getSelectedFiles(),
262                    fileChooser.getCurrentDirectory());
263    }
264    
265    /**
266     * Override the base class method to catch the do update
267     */
268    public void doUpdate() {
269        fileChooser.setCurrentDirectory(new File(getPath()));
270        fileChooser.rescanCurrentDirectory();
271    }
272    
273    /**
274     * Allow multiple file selection.  Override if necessary.
275     *
276     * @return Always returns {@code true}.
277     */
278    protected boolean getAllowMultiple() {
279        return true;
280    }
281    
282    /**
283     * Set whether the user has made a selection that contains data.
284     *
285     * @param have   true to set the haveData property.  Enables the
286     *               loading button
287     */
288    public void setHaveData(boolean have) {
289        super.setHaveData(have);
290        updateStatus();
291    }
292    
293    /**
294     * Set the status message appropriately
295     */
296    protected void updateStatus() {
297        super.updateStatus();
298        if (!getHaveData()) {
299            if (getAllowMultiple()) {
300                setStatus("Select one or more files");
301            } else {
302                setStatus("Select a file");
303            }
304        }
305    }
306    
307    /**
308     * Get the top components for the chooser
309     *
310     * @param comps  the top component
311     */
312    protected void getTopComponents(List comps) {
313        Element chooserNode = getXmlNode();
314        
315        // Force ATTR_DSCOMP to be false before calling super.getTopComponents
316        // We call getDataSourcesComponent later on
317        boolean dscomp = XmlUtil.getAttribute(chooserNode, ATTR_DSCOMP, true);
318        XmlUtil.setAttributes(chooserNode, new String[] { ATTR_DSCOMP, "false" });
319        super.getTopComponents(comps);
320        if (dscomp) {
321            XmlUtil.setAttributes(chooserNode, new String[] { ATTR_DSCOMP, "true" });
322        }
323    }
324    
325    /**
326     * Get the top panel for the chooser
327     * @return the top panel
328     */
329    protected JPanel getTopPanel() {
330        List topComps  = new ArrayList();
331        getTopComponents(topComps);
332        if (topComps.size() == 0) {
333            return null;
334        }
335        JPanel topPanel = GuiUtils.left(GuiUtils.doLayout(topComps, 0, GuiUtils.WT_N, GuiUtils.WT_N));
336        topPanel.setBorder(javax.swing.BorderFactory.createEtchedBorder());
337        
338        return McVGuiUtils.makeLabeledComponent("Options:", topPanel);
339    }
340    
341    /**
342     * Get the bottom panel for the chooser
343     * @return the bottom panel
344     */
345    protected JPanel getBottomPanel() {
346        return null;
347    }
348    
349    /**
350     * Get the center panel for the chooser
351     * @return the center panel
352     */
353    protected JPanel getCenterPanel() {
354        Element chooserNode = getXmlNode();
355
356        fileChooser = doMakeFileChooser(getPath());
357        fileChooser.setPreferredSize(new Dimension(300, 300));
358        fileChooser.setMultiSelectionEnabled(getAllowMultiple());
359        
360        fileChooser.addPropertyChangeListener(
361            JFileChooser.DIRECTORY_CHANGED_PROPERTY,
362            createPropertyListener()
363        );
364        
365        List filters = new ArrayList();
366        String filterString = XmlUtil.getAttribute(chooserNode, ATTR_FILTERS, (String) null);
367        
368        filters.addAll(getDataManager().getFileFilters());
369        if (filterString != null) {
370            filters.addAll(PatternFileFilter.createFilters(filterString));
371        }
372        
373        if (!filters.isEmpty()) {
374            for (int i = 0; i < filters.size(); i++) {
375                fileChooser.addChoosableFileFilter((FileFilter) filters.get(i));
376            }
377            fileChooser.setFileFilter(fileChooser.getAcceptAllFileFilter());
378        }
379        
380        JPanel centerPanel;
381        JComponent accessory = getAccessory();
382        if (accessory == null) {
383            centerPanel = GuiUtils.center(fileChooser);
384        } else {
385            centerPanel = GuiUtils.centerRight(fileChooser, GuiUtils.top(accessory));
386        }
387        centerPanel.setBorder(javax.swing.BorderFactory.createEtchedBorder());
388        setHaveData(false);
389        return McVGuiUtils.makeLabeledComponent("Files:", centerPanel);
390    }
391    
392    /**
393     * Creates a {@link PropertyChangeListener} that listens for
394     * {@link JFileChooser#DIRECTORY_CHANGED_PROPERTY}.
395     *
396     * <p>This is used to disable directory monitoring in directories not
397     * being looked at, as well as enabling monitoring of the directory the
398     * user has chosen.</p>
399     *
400     * @return {@code PropertyChangeListener} that listens for
401     * {@code JFileChooser} directory changes.
402     */
403    protected PropertyChangeListener createPropertyListener() {
404        return evt -> {
405            String name = evt.getPropertyName();
406            if (JFileChooser.DIRECTORY_CHANGED_PROPERTY.equals(name)) {
407                String newPath = evt.getNewValue().toString();
408                handleChangeWatchService(newPath);
409            }
410        };
411    }
412    
413    /**
414     * Change the path that the file chooser is presenting to the user.
415     *
416     * <p>This value will be written to the user's preferences so that the user
417     * can pick up where they left off after restarting McIDAS-V.</p>
418     *
419     * @param newPath Path to set.
420     */
421    public void setPath(String newPath) {
422        String id = PREF_DEFAULTDIR + getId();
423        idv.getStateManager().writePreference(id, newPath);
424    }
425    
426    /**
427     * See the javadoc for {@link #getPath(String)}.
428     *
429     * <p>The difference between the two is that this method passes the value
430     * of {@code System.getProperty("user.home")} to {@link #getPath(String)}
431     * as the default value.</p>
432     *
433     * @return Path to use for the chooser.
434     */
435    public String getPath() {
436        return getPath(System.getProperty("user.home"));
437    }
438    
439    /**
440     * Get the path the {@link JFileChooser} should be using.
441     *
442     * <p>If the path in the user's preferences is {@code null}
443     * (or does not exist), {@code defaultValue} will be returned.</p>
444     * 
445     * <p>If there is a nonexistent path in the preferences file, 
446     * {@link #findValidParent(String)} will be used.</p>
447     *
448     * @param defaultValue Default path to use if there is a {@literal "bad"}
449     *                     path in the user's preferences.
450     *                     Cannot be {@code null}.
451     *
452     * @return Path to use for the chooser.
453     *
454     * @throws NullPointerException if {@code defaultValue} is {@code null}.
455     */
456    public String getPath(final String defaultValue) {
457        Objects.requireNonNull(defaultValue, "Default value may not be null");
458        String tempPath = (String)idv.getPreference(PREF_DEFAULTDIR + getId());
459        if ((tempPath == null)) {
460            tempPath = defaultValue;
461        } else if (!Files.exists(Paths.get(tempPath))) {
462            tempPath = findValidParent(tempPath);
463        }
464        return tempPath;
465    }
466    
467    /**
468     * Respond to path changes in the {@code JFileChooser}.
469     *
470     * <p>This method will disable monitoring of the previous path and then
471     * enable monitoring of {@code newPath}.</p>
472     *
473     * @param newPath New path to begin watching.
474     */
475    public void handleChangeWatchService(final String newPath) {
476        DirectoryWatchService watchService = getStaticMcv().getWatchService();
477        if (watchService != null && watchListener != null) {
478            logger.trace("trying to watch '{}'", newPath);
479            
480            setPath(newPath);
481            
482            handleStopWatchService(Constants.EVENT_FILECHOOSER_STOP,
483                                   "changed directory");
484            
485            handleStartWatchService(Constants.EVENT_FILECHOOSER_START,
486                                    "new directory");
487        }
488    }
489    
490    /**
491     * Begin monitoring the directory returned by {@link #getPath()} for
492     * changes.
493     *
494     * @param topic Artifact from {@code EventBus} annotation. Not used.
495     * @param reason Optional {@literal "Reason"} for starting.
496     *               Helpful for logging.
497     */
498    @EventTopicSubscriber(topic=Constants.EVENT_FILECHOOSER_START)
499    public void handleStartWatchService(final String topic,
500                                        final Object reason)
501    {
502        McIDASV mcv = getStaticMcv();
503        boolean offscreen = mcv.getArgsManager().getIsOffScreen();
504        boolean initDone = mcv.getHaveInitialized();
505        String watchPath = getPath();
506        
507        DirectoryWatchService watchService = getStaticMcv().getWatchService();
508        if (!watchService.isRunning()) {
509            logger.warn("watch service is down! attempting restart...");
510            watchService.start();
511        }
512        
513        if ((watchListener == null) && isTrulyVisible() && !offscreen && initDone) {
514            try {
515                watchListener = createWatcher();
516                mcv.watchDirectory(watchPath, "*", watchListener);
517                logger.trace("watching '{}' pattern: '{}' running: '{}' (reason: '{}')", watchPath, "*", watchService.isRunning(), reason);
518            } catch (IOException e) {
519                logger.error("error creating watch service", e);
520            }
521        }
522    }
523    
524    /**
525     * Disable directory monitoring (if it was enabled in the first place).
526     *
527     * @param topic Artifact from {@code EventBus} annotation. Not used.
528     * @param reason Optional {@literal "Reason"} for starting.
529     *               Helpful for logging.
530     */
531    @EventTopicSubscriber(topic=Constants.EVENT_FILECHOOSER_STOP)
532    public void handleStopWatchService(final String topic,
533                                       final Object reason)
534    {
535        logger.trace("stopping service (reason: '{}')", reason);
536        
537        DirectoryWatchService service = getStaticMcv().getWatchService();
538        service.unregister(watchListener);
539        
540        service = null;
541        watchListener = null;
542        logger.trace("should be good to go!");
543    }
544    
545    /**
546     * Creates a directory monitoring
547     * {@link edu.wisc.ssec.mcidasv.util.pathwatcher.Service Service}.
548     *
549     * @return Directory monitor that will respond to changes.
550     */
551    protected OnFileChangeListener createWatcher() {
552        watchListener = new OnFileChangeListener() {
553            
554            /** {@inheritDoc} */
555            @Override public void onFileCreate(String filePath) {
556                DirectoryWatchService service = getStaticMcv().getWatchService();
557                if ((fileChooser != null) && service.isRunning()) {
558                    SwingUtilities.invokeLater(() -> doUpdate());
559                }
560            }
561            
562            /** {@inheritDoc} */
563            @Override public void onFileModify(String filePath) {
564                DirectoryWatchService service = getStaticMcv().getWatchService();
565                if ((fileChooser != null) && service.isRunning()) {
566                    SwingUtilities.invokeLater(() -> doUpdate());
567                }
568            }
569            
570            /** {@inheritDoc} */
571            @Override public void onFileDelete(String filePath) {
572                DirectoryWatchService service = getStaticMcv().getWatchService();
573                if ((fileChooser != null) && service.isRunning()) {
574                    setPath(findValidParent(filePath));
575                    SwingUtilities.invokeLater(() -> doUpdate());
576                }
577            }
578            
579            /** {@inheritDoc} */
580            @Override public void onWatchInvalidation(String filePath) {
581                DirectoryWatchService service = getStaticMcv().getWatchService();
582                if ((fileChooser != null) && service.isRunning()) {
583                    setPath(findValidParent(filePath));
584                    SwingUtilities.invokeLater(() -> doUpdate());
585                }
586            }
587        };
588        return watchListener;
589    }
590
591    /**
592     * Find the closest valid {@literal "parent"} of the given path.
593     * 
594     * <p>Example: {@code /tmp/foo/bar/baz/} is {@code filePath}, but only 
595     * {@code /tmp/foo} exists. This method will return {@code /tmp/foo}.</p>
596     * 
597     * @param filePath Path to use a base. Cannot be {@code null}, but the path
598     *                 does not need to exist.
599     * 
600     * @return Closest existing ancestor of {@code filePath}.
601     */
602    public static String findValidParent(String filePath) {
603        Path p = Paths.get(filePath);
604        while (!Files.exists(p)) {
605            p = p.getParent();
606        }
607        return p.toString();
608    }
609    
610    private JLabel statusLabel = new JLabel("Status");
611    
612    @Override public void setStatus(String statusString, String foo) {
613        if (statusString == null) {
614            statusString = "";
615        }
616        statusLabel.setText(statusString);
617    }
618    
619    /**
620     * Create a more McIDAS-V-like GUI layout
621     */
622    protected JComponent doMakeContents() {
623        // Run super.doMakeContents()
624        // It does some initialization on private components that we can't get at
625        JComponent parentContents = super.doMakeContents();
626        Element chooserNode = getXmlNode();
627        
628        String pathFromXml =
629            XmlUtil.getAttribute(chooserNode, ATTR_PATH, (String)null);
630        if (pathFromXml != null && Paths.get(pathFromXml).toFile().exists()) {
631            setPath(pathFromXml);
632        }
633        
634        JComponent typeComponent = new JPanel();
635        if (XmlUtil.getAttribute(chooserNode, ATTR_DSCOMP, true)) {
636            typeComponent = getDataSourcesComponent();
637        }
638        
639        if (defaultDataSourceName != null) {
640            typeComponent = new JLabel(defaultDataSourceName);
641            McVGuiUtils.setLabelBold((JLabel) typeComponent, true);
642            McVGuiUtils.setComponentHeight(typeComponent, new JComboBox());
643        }
644        
645        // Create the different panels... extending classes can override these
646        topPanel = getTopPanel();
647        centerPanel = getCenterPanel();
648        bottomPanel = getBottomPanel();
649        
650        JPanel innerPanel = centerPanel;
651        if (topPanel != null && bottomPanel != null) {
652            innerPanel = McVGuiUtils.topCenterBottom(topPanel, centerPanel, bottomPanel);
653        } else if (topPanel != null) {
654            innerPanel = McVGuiUtils.topBottom(topPanel, centerPanel, McVGuiUtils.Prefer.BOTTOM);
655        } else if (bottomPanel != null) {
656            innerPanel = McVGuiUtils.topBottom(centerPanel, bottomPanel, McVGuiUtils.Prefer.TOP);
657        }
658        // Start building the whole thing here
659        JPanel outerPanel = new JPanel();
660        
661        JLabel typeLabel = McVGuiUtils.makeLabelRight(getDataSourcesLabel());
662        
663        JLabel statusLabelLabel = McVGuiUtils.makeLabelRight("");
664        
665        McVGuiUtils.setLabelPosition(statusLabel, Position.RIGHT);
666        McVGuiUtils.setComponentColor(statusLabel, TextColor.STATUS);
667        
668        JButton helpButton = McVGuiUtils.makeImageButton(ICON_HELP, "Show help");
669        helpButton.setActionCommand(GuiUtils.CMD_HELP);
670        helpButton.addActionListener(this);
671        
672        JButton refreshButton = McVGuiUtils.makeImageButton(ICON_REFRESH, "Refresh");
673        refreshButton.setActionCommand(GuiUtils.CMD_UPDATE);
674        refreshButton.addActionListener(this);
675        
676        McVGuiUtils.setButtonImage(loadButton, ICON_ACCEPT_SMALL);
677        McVGuiUtils.setComponentWidth(loadButton, Width.DOUBLE);
678        
679        // This is how we know if the action was initiated by a button press
680        loadButton.addActionListener(e -> {
681            buttonPressed = true;
682            Misc.runInABit(1000, () -> buttonPressed = false);
683        });
684        
685        GroupLayout layout = new GroupLayout(outerPanel);
686        outerPanel.setLayout(layout);
687        layout.setHorizontalGroup(
688            layout.createParallelGroup(LEADING)
689            .addGroup(TRAILING, layout.createSequentialGroup()
690                .addGroup(layout.createParallelGroup(TRAILING)
691                    .addGroup(layout.createSequentialGroup()
692                        .addContainerGap()
693                        .addComponent(helpButton)
694                        .addGap(GAP_RELATED)
695                        .addComponent(refreshButton)
696                        .addPreferredGap(RELATED)
697                        .addComponent(loadButton))
698                        .addGroup(LEADING, layout.createSequentialGroup()
699                        .addContainerGap()
700                        .addGroup(layout.createParallelGroup(LEADING)
701                            .addComponent(innerPanel, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE)
702                            .addGroup(layout.createSequentialGroup()
703                                .addComponent(typeLabel)
704                                .addGap(GAP_RELATED)
705                                .addComponent(typeComponent))
706                            .addGroup(layout.createSequentialGroup()
707                                .addComponent(statusLabelLabel)
708                                .addGap(GAP_RELATED)
709                                .addComponent(statusLabel, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE)))))
710                .addContainerGap())
711        );
712        layout.setVerticalGroup(
713            layout.createParallelGroup(LEADING)
714            .addGroup(layout.createSequentialGroup()
715                .addContainerGap()
716                .addGroup(layout.createParallelGroup(BASELINE)
717                    .addComponent(typeLabel)
718                    .addComponent(typeComponent))
719                .addPreferredGap(UNRELATED)
720                .addComponent(innerPanel, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE)
721                .addPreferredGap(UNRELATED)
722                .addGroup(layout.createParallelGroup(BASELINE)
723                    .addComponent(statusLabelLabel)
724                    .addComponent(statusLabel))
725                .addPreferredGap(UNRELATED)
726                .addGroup(layout.createParallelGroup(BASELINE)
727                    .addComponent(loadButton)
728                    .addComponent(refreshButton)
729                    .addComponent(helpButton))
730                .addContainerGap())
731        );
732        outerPanel.addAncestorListener(this);
733        return outerPanel;
734    }
735    
736    /** {@inheritDoc} */
737    @Override public void ancestorAdded(AncestorEvent event) {
738        // keep the calls to setTrulyVisible as the first step. that way 
739        // isTrulyVisible should work as expected.
740        setTrulyVisible(true);
741        
742        handleStartWatchService(Constants.EVENT_FILECHOOSER_START, 
743                                "chooser is visible");
744        SwingUtilities.invokeLater(this::doUpdate);
745    }
746    
747    /** {@inheritDoc} */
748    @Override public void ancestorRemoved(AncestorEvent event) {
749        // keep the calls to setTrulyVisible as the first step. that way 
750        // isTrulyVisible should work as expected.
751        setTrulyVisible(false);
752        
753        handleStopWatchService(Constants.EVENT_FILECHOOSER_STOP, 
754                               "chooser is not visible");
755    }
756    
757    /**
758     * Not implemented.
759     *
760     * @param event Ignored.
761     */
762    @Override public void ancestorMoved(AncestorEvent event) {}
763    
764    /**
765     * Determine if this file chooser is actually visible to the user.
766     *
767     * @return Whether or not this component has been made visible.
768     */
769    public boolean isTrulyVisible() {
770        return trulyVisible;
771    }
772    
773    /**
774     * Set whether or not this file chooser is actually visible to the user.
775     *
776     * @param value {@code true} means visible.
777     */
778    private void setTrulyVisible(boolean value) {
779        trulyVisible = value;
780    }
781    
782}