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 */
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, 
458                       "Default value may not be null");
459        String tempPath = (String)idv.getPreference(PREF_DEFAULTDIR + getId());
460        try {
461            if ((tempPath == null)) {
462                tempPath = defaultValue;
463            } else if (!Files.exists(Paths.get(tempPath))) {
464                tempPath = findValidParent(tempPath);
465            }
466        } catch (Exception e) {
467            logger.warn("Could not find valid parent directory for '"+tempPath+"', using '"+defaultValue+'\'');
468            tempPath = defaultValue;
469        }
470        return tempPath;
471    }
472    
473    /**
474     * Respond to path changes in the {@code JFileChooser}.
475     *
476     * <p>This method will disable monitoring of the previous path and then
477     * enable monitoring of {@code newPath}.</p>
478     *
479     * @param newPath New path to begin watching.
480     */
481    public void handleChangeWatchService(final String newPath) {
482        DirectoryWatchService watchService = getStaticMcv().getWatchService();
483        if (watchService != null && watchListener != null) {
484            logger.trace("trying to watch '{}'", newPath);
485            
486            setPath(newPath);
487            
488            handleStopWatchService(Constants.EVENT_FILECHOOSER_STOP,
489                                   "changed directory");
490            
491            handleStartWatchService(Constants.EVENT_FILECHOOSER_START,
492                                    "new directory");
493        }
494    }
495    
496    /**
497     * Begin monitoring the directory returned by {@link #getPath()} for
498     * changes.
499     *
500     * @param topic Artifact from {@code EventBus} annotation. Not used.
501     * @param reason Optional {@literal "Reason"} for starting.
502     *               Helpful for logging.
503     */
504    @EventTopicSubscriber(topic=Constants.EVENT_FILECHOOSER_START)
505    public void handleStartWatchService(final String topic,
506                                        final Object reason)
507    {
508        McIDASV mcv = getStaticMcv();
509        boolean offscreen = mcv.getArgsManager().getIsOffScreen();
510        boolean initDone = mcv.getHaveInitialized();
511        String watchPath = getPath();
512        
513        DirectoryWatchService watchService = getStaticMcv().getWatchService();
514        if (!watchService.isRunning()) {
515            logger.warn("watch service is down! attempting restart...");
516            watchService.start();
517        }
518        
519        if ((watchListener == null) && isTrulyVisible() && !offscreen && initDone) {
520            try {
521                watchListener = createWatcher();
522                mcv.watchDirectory(watchPath, "*", watchListener);
523                logger.trace("watching '{}' pattern: '{}' running: '{}' (reason: '{}')", watchPath, "*", watchService.isRunning(), reason);
524            } catch (IOException e) {
525                logger.error("error creating watch service", e);
526            }
527        }
528    }
529    
530    /**
531     * Disable directory monitoring (if it was enabled in the first place).
532     *
533     * @param topic Artifact from {@code EventBus} annotation. Not used.
534     * @param reason Optional {@literal "Reason"} for starting.
535     *               Helpful for logging.
536     */
537    @EventTopicSubscriber(topic=Constants.EVENT_FILECHOOSER_STOP)
538    public void handleStopWatchService(final String topic,
539                                       final Object reason)
540    {
541        logger.trace("stopping service (reason: '{}')", reason);
542        
543        DirectoryWatchService service = getStaticMcv().getWatchService();
544        service.unregister(watchListener);
545        
546        service = null;
547        watchListener = null;
548        logger.trace("should be good to go!");
549    }
550    
551    /**
552     * Creates a directory monitoring
553     * {@link edu.wisc.ssec.mcidasv.util.pathwatcher.Service Service}.
554     *
555     * @return Directory monitor that will respond to changes.
556     */
557    protected OnFileChangeListener createWatcher() {
558        watchListener = new OnFileChangeListener() {
559            
560            /** {@inheritDoc} */
561            @Override public void onFileCreate(String filePath) {
562                DirectoryWatchService service = getStaticMcv().getWatchService();
563                if ((fileChooser != null) && service.isRunning()) {
564                    SwingUtilities.invokeLater(() -> doUpdate());
565                }
566            }
567            
568            /** {@inheritDoc} */
569            @Override public void onFileModify(String filePath) {
570                DirectoryWatchService service = getStaticMcv().getWatchService();
571                if ((fileChooser != null) && service.isRunning()) {
572                    SwingUtilities.invokeLater(() -> doUpdate());
573                }
574            }
575            
576            /** {@inheritDoc} */
577            @Override public void onFileDelete(String filePath) {
578                DirectoryWatchService service = getStaticMcv().getWatchService();
579                if ((fileChooser != null) && service.isRunning()) {
580                    setPath(findValidParent(filePath));
581                    SwingUtilities.invokeLater(() -> doUpdate());
582                }
583            }
584            
585            /** {@inheritDoc} */
586            @Override public void onWatchInvalidation(String filePath) {
587                DirectoryWatchService service = getStaticMcv().getWatchService();
588                if ((fileChooser != null) && service.isRunning()) {
589                    setPath(findValidParent(filePath));
590                    SwingUtilities.invokeLater(() -> doUpdate());
591                }
592            }
593        };
594        return watchListener;
595    }
596
597    /**
598     * Find the closest valid {@literal "parent"} of the given path.
599     * 
600     * <p>Example: {@code /tmp/foo/bar/baz/} is {@code filePath}, but only 
601     * {@code /tmp/foo} exists. This method will return {@code /tmp/foo}.</p>
602     * 
603     * @param filePath Path to use a base. Cannot be {@code null}, but the path
604     *                 does not need to exist.
605     * 
606     * @return Closest existing ancestor of {@code filePath}.
607     */
608    public static String findValidParent(String filePath) {
609        Path p = Paths.get(filePath);
610        while (!Files.exists(p)) {
611            p = p.getParent();
612        }
613        return p.toString();
614    }
615    
616    private JLabel statusLabel = new JLabel("Status");
617    
618    @Override public void setStatus(String statusString, String foo) {
619        if (statusString == null) {
620            statusString = "";
621        }
622        statusLabel.setText(statusString);
623    }
624    
625    /**
626     * Create a more McIDAS-V-like GUI layout
627     */
628    protected JComponent doMakeContents() {
629        // Run super.doMakeContents()
630        // It does some initialization on private components that we can't get at
631        JComponent parentContents = super.doMakeContents();
632        Element chooserNode = getXmlNode();
633        
634        String pathFromXml =
635            XmlUtil.getAttribute(chooserNode, ATTR_PATH, (String)null);
636        if (pathFromXml != null && Paths.get(pathFromXml).toFile().exists()) {
637            setPath(pathFromXml);
638        }
639        
640        JComponent typeComponent = new JPanel();
641        if (XmlUtil.getAttribute(chooserNode, ATTR_DSCOMP, true)) {
642            typeComponent = getDataSourcesComponent();
643        }
644        
645        if (defaultDataSourceName != null) {
646            typeComponent = new JLabel(defaultDataSourceName);
647            McVGuiUtils.setLabelBold((JLabel) typeComponent, true);
648            McVGuiUtils.setComponentHeight(typeComponent, new JComboBox());
649        }
650        
651        // Create the different panels... extending classes can override these
652        topPanel = getTopPanel();
653        centerPanel = getCenterPanel();
654        bottomPanel = getBottomPanel();
655        
656        JPanel innerPanel = centerPanel;
657        if (topPanel != null && bottomPanel != null) {
658            innerPanel = McVGuiUtils.topCenterBottom(topPanel, centerPanel, bottomPanel);
659        } else if (topPanel != null) {
660            innerPanel = McVGuiUtils.topBottom(topPanel, centerPanel, McVGuiUtils.Prefer.BOTTOM);
661        } else if (bottomPanel != null) {
662            innerPanel = McVGuiUtils.topBottom(centerPanel, bottomPanel, McVGuiUtils.Prefer.TOP);
663        }
664        // Start building the whole thing here
665        JPanel outerPanel = new JPanel();
666        
667        JLabel typeLabel = McVGuiUtils.makeLabelRight(getDataSourcesLabel());
668        
669        JLabel statusLabelLabel = McVGuiUtils.makeLabelRight("");
670        
671        McVGuiUtils.setLabelPosition(statusLabel, Position.RIGHT);
672        McVGuiUtils.setComponentColor(statusLabel, TextColor.STATUS);
673        
674        JButton helpButton = McVGuiUtils.makeImageButton(ICON_HELP, "Show help");
675        helpButton.setActionCommand(GuiUtils.CMD_HELP);
676        helpButton.addActionListener(this);
677        
678        JButton refreshButton = McVGuiUtils.makeImageButton(ICON_REFRESH, "Refresh");
679        refreshButton.setActionCommand(GuiUtils.CMD_UPDATE);
680        refreshButton.addActionListener(this);
681        
682        McVGuiUtils.setButtonImage(loadButton, ICON_ACCEPT_SMALL);
683        McVGuiUtils.setComponentWidth(loadButton, Width.DOUBLE);
684        
685        // This is how we know if the action was initiated by a button press
686        loadButton.addActionListener(e -> {
687            buttonPressed = true;
688            Misc.runInABit(1000, () -> buttonPressed = false);
689        });
690        
691        GroupLayout layout = new GroupLayout(outerPanel);
692        outerPanel.setLayout(layout);
693        layout.setHorizontalGroup(
694            layout.createParallelGroup(LEADING)
695            .addGroup(TRAILING, layout.createSequentialGroup()
696                .addGroup(layout.createParallelGroup(TRAILING)
697                    .addGroup(layout.createSequentialGroup()
698                        .addContainerGap()
699                        .addComponent(helpButton)
700                        .addGap(GAP_RELATED)
701                        .addComponent(refreshButton)
702                        .addPreferredGap(RELATED)
703                        .addComponent(loadButton))
704                        .addGroup(LEADING, layout.createSequentialGroup()
705                        .addContainerGap()
706                        .addGroup(layout.createParallelGroup(LEADING)
707                            .addComponent(innerPanel, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE)
708                            .addGroup(layout.createSequentialGroup()
709                                .addComponent(typeLabel)
710                                .addGap(GAP_RELATED)
711                                .addComponent(typeComponent))
712                            .addGroup(layout.createSequentialGroup()
713                                .addComponent(statusLabelLabel)
714                                .addGap(GAP_RELATED)
715                                .addComponent(statusLabel, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE)))))
716                .addContainerGap())
717        );
718        layout.setVerticalGroup(
719            layout.createParallelGroup(LEADING)
720            .addGroup(layout.createSequentialGroup()
721                .addContainerGap()
722                .addGroup(layout.createParallelGroup(BASELINE)
723                    .addComponent(typeLabel)
724                    .addComponent(typeComponent))
725                .addPreferredGap(UNRELATED)
726                .addComponent(innerPanel, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE)
727                .addPreferredGap(UNRELATED)
728                .addGroup(layout.createParallelGroup(BASELINE)
729                    .addComponent(statusLabelLabel)
730                    .addComponent(statusLabel))
731                .addPreferredGap(UNRELATED)
732                .addGroup(layout.createParallelGroup(BASELINE)
733                    .addComponent(loadButton)
734                    .addComponent(refreshButton)
735                    .addComponent(helpButton))
736                .addContainerGap())
737        );
738        outerPanel.addAncestorListener(this);
739        return outerPanel;
740    }
741    
742    /** {@inheritDoc} */
743    @Override public void ancestorAdded(AncestorEvent event) {
744        // keep the calls to setTrulyVisible as the first step. that way 
745        // isTrulyVisible should work as expected.
746        setTrulyVisible(true);
747        
748        handleStartWatchService(Constants.EVENT_FILECHOOSER_START, 
749                                "chooser is visible");
750        SwingUtilities.invokeLater(this::doUpdate);
751    }
752    
753    /** {@inheritDoc} */
754    @Override public void ancestorRemoved(AncestorEvent event) {
755        // keep the calls to setTrulyVisible as the first step. that way 
756        // isTrulyVisible should work as expected.
757        setTrulyVisible(false);
758        
759        handleStopWatchService(Constants.EVENT_FILECHOOSER_STOP, 
760                               "chooser is not visible");
761    }
762    
763    /**
764     * Not implemented.
765     *
766     * @param event Ignored.
767     */
768    @Override public void ancestorMoved(AncestorEvent event) {}
769    
770    /**
771     * Determine if this file chooser is actually visible to the user.
772     *
773     * @return Whether or not this component has been made visible.
774     */
775    public boolean isTrulyVisible() {
776        return trulyVisible;
777    }
778    
779    /**
780     * Set whether or not this file chooser is actually visible to the user.
781     *
782     * @param value {@code true} means visible.
783     */
784    private void setTrulyVisible(boolean value) {
785        trulyVisible = value;
786    }
787    
788}