001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.download;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.BorderLayout;
008import java.awt.Component;
009import java.awt.Dimension;
010import java.awt.FlowLayout;
011import java.awt.GridBagLayout;
012import java.awt.event.ActionEvent;
013import java.awt.event.ComponentAdapter;
014import java.awt.event.ComponentEvent;
015import java.awt.event.InputEvent;
016import java.awt.event.KeyEvent;
017import java.awt.event.WindowAdapter;
018import java.awt.event.WindowEvent;
019import java.util.ArrayList;
020import java.util.List;
021import java.util.Optional;
022import java.util.stream.Collectors;
023import java.util.stream.IntStream;
024
025import javax.swing.AbstractAction;
026import javax.swing.Icon;
027import javax.swing.JButton;
028import javax.swing.JCheckBox;
029import javax.swing.JComponent;
030import javax.swing.JDialog;
031import javax.swing.JLabel;
032import javax.swing.JPanel;
033import javax.swing.JSplitPane;
034import javax.swing.JTabbedPane;
035import javax.swing.KeyStroke;
036import javax.swing.event.ChangeEvent;
037import javax.swing.event.ChangeListener;
038
039import org.openstreetmap.josm.actions.ExpertToggleAction;
040import org.openstreetmap.josm.data.Bounds;
041import org.openstreetmap.josm.data.preferences.BooleanProperty;
042import org.openstreetmap.josm.data.preferences.IntegerProperty;
043import org.openstreetmap.josm.data.preferences.StringProperty;
044import org.openstreetmap.josm.gui.MainApplication;
045import org.openstreetmap.josm.gui.MapView;
046import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils;
047import org.openstreetmap.josm.gui.help.ContextSensitiveHelpAction;
048import org.openstreetmap.josm.gui.help.HelpUtil;
049import org.openstreetmap.josm.gui.layer.OsmDataLayer;
050import org.openstreetmap.josm.gui.util.GuiHelper;
051import org.openstreetmap.josm.gui.util.WindowGeometry;
052import org.openstreetmap.josm.io.NetworkManager;
053import org.openstreetmap.josm.io.OnlineResource;
054import org.openstreetmap.josm.plugins.PluginHandler;
055import org.openstreetmap.josm.spi.preferences.Config;
056import org.openstreetmap.josm.tools.GBC;
057import org.openstreetmap.josm.tools.ImageProvider;
058import org.openstreetmap.josm.tools.InputMapUtils;
059import org.openstreetmap.josm.tools.JosmRuntimeException;
060import org.openstreetmap.josm.tools.ListenerList;
061import org.openstreetmap.josm.tools.Logging;
062import org.openstreetmap.josm.tools.OsmUrlToBounds;
063
064/**
065 * Dialog displayed to the user to download mapping data.
066 */
067public class DownloadDialog extends JDialog {
068
069    private static final IntegerProperty DOWNLOAD_TAB = new IntegerProperty("download.tab", 0);
070    private static final StringProperty DOWNLOAD_SOURCE_TAB = new StringProperty("download.source.tab", OSMDownloadSource.SIMPLE_NAME);
071    private static final BooleanProperty DOWNLOAD_AUTORUN = new BooleanProperty("download.autorun", false);
072    private static final BooleanProperty DOWNLOAD_ZOOMTODATA = new BooleanProperty("download.zoomtodata", true);
073
074    /** the unique instance of the download dialog */
075    private static DownloadDialog instance;
076
077    /**
078     * Replies the unique instance of the download dialog
079     *
080     * @return the unique instance of the download dialog
081     */
082    public static synchronized DownloadDialog getInstance() {
083        if (instance == null) {
084            instance = new DownloadDialog(MainApplication.getMainFrame());
085        }
086        return instance;
087    }
088
089    private static final ListenerList<DownloadSourceListener> downloadSourcesListeners = ListenerList.create();
090    private static final List<DownloadSource<?>> downloadSources = new ArrayList<>();
091    static {
092        // add default download sources
093        addDownloadSource(new OSMDownloadSource());
094        addDownloadSource(new OverpassDownloadSource());
095    }
096
097    protected final transient List<DownloadSelection> downloadSelections = new ArrayList<>();
098    protected final JTabbedPane tpDownloadAreaSelectors = new JTabbedPane();
099    protected final DownloadSourceTabs downloadSourcesTab = new DownloadSourceTabs();
100
101    protected JCheckBox cbStartup;
102    protected JCheckBox cbZoomToDownloadedData;
103    protected SlippyMapChooser slippyMapChooser;
104    protected JPanel mainPanel;
105    protected DownloadDialogSplitPane dialogSplit;
106
107    /*
108     * Keep the reference globally to avoid having it garbage collected
109     */
110    protected final transient ExpertToggleAction.ExpertModeChangeListener expertListener =
111            getExpertModeListenerForDownloadSources();
112    protected transient Bounds currentBounds;
113    protected boolean canceled;
114
115    protected JButton btnDownload;
116    protected JButton btnDownloadNewLayer;
117    protected JButton btnCancel;
118    protected JButton btnHelp;
119
120    /**
121     * Builds the main panel of the dialog.
122     * @return The panel of the dialog.
123     */
124    protected final JPanel buildMainPanel() {
125        mainPanel = new JPanel(new GridBagLayout());
126
127        // must be created before hook
128        slippyMapChooser = new SlippyMapChooser();
129
130        // predefined download selections
131        downloadSelections.add(slippyMapChooser);
132        downloadSelections.add(new BookmarkSelection());
133        downloadSelections.add(new BoundingBoxSelection());
134        downloadSelections.add(new PlaceSelection());
135        downloadSelections.add(new TileSelection());
136
137        // add selections from plugins
138        PluginHandler.addDownloadSelection(downloadSelections);
139
140        // register all default download selections
141        for (DownloadSelection s : downloadSelections) {
142            s.addGui(this);
143        }
144
145        // allow to collapse the panes, but reserve some space for tabs
146        downloadSourcesTab.setMinimumSize(new Dimension(0, 25));
147        tpDownloadAreaSelectors.setMinimumSize(new Dimension(0, 0));
148
149        dialogSplit = new DownloadDialogSplitPane(
150                downloadSourcesTab,
151                tpDownloadAreaSelectors);
152
153        ChangeListener tabChangedListener = getDownloadSourceTabChangeListener();
154        tabChangedListener.stateChanged(new ChangeEvent(downloadSourcesTab));
155        downloadSourcesTab.addChangeListener(tabChangedListener);
156
157        mainPanel.add(dialogSplit, GBC.eol().fill());
158
159        cbStartup = new JCheckBox(tr("Open this dialog on startup"));
160        cbStartup.setToolTipText(
161                tr("<html>Autostart ''Download from OSM'' dialog every time JOSM is started.<br>" +
162                        "You can open it manually from File menu or toolbar.</html>"));
163        cbStartup.addActionListener(e -> DOWNLOAD_AUTORUN.put(cbStartup.isSelected()));
164
165        cbZoomToDownloadedData = new JCheckBox(tr("Zoom to downloaded data"));
166        cbZoomToDownloadedData.setToolTipText(tr("Select to zoom to entire newly downloaded data."));
167
168        mainPanel.add(cbStartup, GBC.std().anchor(GBC.WEST).insets(15, 5, 5, 5));
169        mainPanel.add(cbZoomToDownloadedData, GBC.std().anchor(GBC.WEST).insets(15, 5, 5, 5));
170
171        ExpertToggleAction.addVisibilitySwitcher(cbZoomToDownloadedData);
172
173        mainPanel.add(new JLabel(), GBC.eol()); // place info label at a new line
174        JLabel infoLabel = new JLabel(
175                tr("Use left click&drag to select area, arrows or right mouse button to scroll map, wheel or +/- to zoom."));
176        mainPanel.add(infoLabel, GBC.eol().anchor(GBC.CENTER).insets(0, 0, 0, 0));
177
178        ExpertToggleAction.addExpertModeChangeListener(isExpert -> infoLabel.setVisible(!isExpert), true);
179
180        return mainPanel;
181    }
182
183    /**
184     * Builds the button pane of the dialog.
185     * @return The button panel of the dialog.
186     */
187    protected final JPanel buildButtonPanel() {
188        btnDownload = new JButton(new DownloadAction(false));
189        btnDownloadNewLayer = new JButton(new DownloadAction(true));
190        btnCancel = new JButton(new CancelAction());
191        btnHelp = new JButton(
192                new ContextSensitiveHelpAction(getRootPane().getClientProperty("help").toString()));
193
194        JPanel pnl = new JPanel(new FlowLayout());
195
196        pnl.add(btnDownload);
197        pnl.add(btnDownloadNewLayer);
198        pnl.add(btnCancel);
199        pnl.add(btnHelp);
200
201        InputMapUtils.enableEnter(btnDownload);
202        InputMapUtils.enableEnter(btnCancel);
203        InputMapUtils.addEscapeAction(getRootPane(), btnCancel.getAction());
204        InputMapUtils.enableEnter(btnHelp);
205
206        InputMapUtils.addEnterActionWhenAncestor(cbStartup, btnDownload.getAction());
207        InputMapUtils.addEnterActionWhenAncestor(cbZoomToDownloadedData, btnDownload.getAction());
208        InputMapUtils.addCtrlEnterAction(pnl, btnDownload.getAction());
209
210        return pnl;
211    }
212
213    /**
214     * Constructs a new {@code DownloadDialog}.
215     * @param parent the parent component
216     */
217    public DownloadDialog(Component parent) {
218        this(parent, ht("/Action/Download"));
219    }
220
221    /**
222     * Constructs a new {@code DownloadDialog}.
223     * @param parent the parent component
224     * @param helpTopic the help topic to assign
225     */
226    public DownloadDialog(Component parent, String helpTopic) {
227        super(GuiHelper.getFrameForComponent(parent), tr("Download"), ModalityType.DOCUMENT_MODAL);
228        HelpUtil.setHelpContext(getRootPane(), helpTopic);
229        getContentPane().setLayout(new BorderLayout());
230        getContentPane().add(buildMainPanel(), BorderLayout.CENTER);
231        getContentPane().add(buildButtonPanel(), BorderLayout.SOUTH);
232
233        getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(
234                KeyStroke.getKeyStroke(KeyEvent.VK_V, InputEvent.CTRL_DOWN_MASK), "checkClipboardContents");
235
236        getRootPane().getActionMap().put("checkClipboardContents", new AbstractAction() {
237            @Override
238            public void actionPerformed(ActionEvent e) {
239                String clip = ClipboardUtils.getClipboardStringContent();
240                if (clip == null) {
241                    return;
242                }
243                Bounds b = OsmUrlToBounds.parse(clip);
244                if (b != null) {
245                    boundingBoxChanged(new Bounds(b), null);
246                }
247            }
248        });
249        addWindowListener(new WindowEventHandler());
250        ExpertToggleAction.addExpertModeChangeListener(expertListener);
251        restoreSettings();
252
253        // if no bounding box is selected make sure it is still propagated.
254        if (currentBounds == null) {
255            boundingBoxChanged(null, null);
256        }
257    }
258
259    /**
260     * Distributes a "bounding box changed" from one DownloadSelection
261     * object to the others, so they may update or clear their input fields. Also informs
262     * download sources about the change, so they can react on it.
263     * @param b new current bounds
264     *
265     * @param eventSource - the DownloadSelection object that fired this notification.
266     */
267    public void boundingBoxChanged(Bounds b, DownloadSelection eventSource) {
268        this.currentBounds = b;
269        for (DownloadSelection s : downloadSelections) {
270            if (s != eventSource) {
271                s.setDownloadArea(currentBounds);
272            }
273        }
274
275        for (AbstractDownloadSourcePanel<?> ds : downloadSourcesTab.getAllPanels()) {
276            ds.boundingBoxChanged(b);
277        }
278    }
279
280    /**
281     * Starts download for the given bounding box
282     * @param b bounding box to download
283     */
284    public void startDownload(Bounds b) {
285        this.currentBounds = b;
286        startDownload();
287    }
288
289    /**
290     * Starts download.
291     */
292    public void startDownload() {
293        btnDownload.doClick();
294    }
295
296    /**
297     * Replies true if the user requires to zoom to new downloaded data
298     *
299     * @return true if the user requires to zoom to new downloaded data
300     * @since 11658
301     */
302    public boolean isZoomToDownloadedDataRequired() {
303        return cbZoomToDownloadedData.isSelected();
304    }
305
306    /**
307     * Determines if the dialog autorun is enabled in preferences.
308     * @return {@code true} if the download dialog must be open at startup, {@code false} otherwise.
309     */
310    public static boolean isAutorunEnabled() {
311        return DOWNLOAD_AUTORUN.get();
312    }
313
314    /**
315     * Adds a new download area selector to the download dialog.
316     *
317     * @param selector the download are selector.
318     * @param displayName the display name of the selector.
319     */
320    public void addDownloadAreaSelector(JPanel selector, String displayName) {
321        tpDownloadAreaSelectors.add(displayName, selector);
322    }
323
324    /**
325     * Adds a new download source to the download dialog if it is not added.
326     *
327     * @param downloadSource The download source to be added.
328     * @param <T> The type of the download data.
329     * @throws JosmRuntimeException If the download source is already added. Note, download sources are
330     * compared by their reference.
331     * @since 12878
332     */
333    public static <T> void addDownloadSource(DownloadSource<T> downloadSource) {
334        if (downloadSources.contains(downloadSource)) {
335            throw new JosmRuntimeException("The download source you are trying to add already exists.");
336        }
337
338        downloadSources.add(downloadSource);
339        downloadSourcesListeners.fireEvent(l -> l.downloadSourceAdded(downloadSource));
340    }
341
342    /**
343     * Refreshes the tile sources.
344     * @since 6364
345     */
346    public final void refreshTileSources() {
347        if (slippyMapChooser != null) {
348            slippyMapChooser.refreshTileSources();
349        }
350    }
351
352    /**
353     * Remembers the current settings in the download dialog.
354     */
355    public void rememberSettings() {
356        DOWNLOAD_TAB.put(tpDownloadAreaSelectors.getSelectedIndex());
357        downloadSourcesTab.getAllPanels().forEach(AbstractDownloadSourcePanel::rememberSettings);
358        downloadSourcesTab.getSelectedPanel().ifPresent(panel -> DOWNLOAD_SOURCE_TAB.put(panel.getSimpleName()));
359        DOWNLOAD_ZOOMTODATA.put(cbZoomToDownloadedData.isSelected());
360        if (currentBounds != null) {
361            Config.getPref().put("osm-download.bounds", currentBounds.encodeAsString(";"));
362        }
363    }
364
365    /**
366     * Restores the previous settings in the download dialog.
367     */
368    public void restoreSettings() {
369        cbStartup.setSelected(isAutorunEnabled());
370        cbZoomToDownloadedData.setSelected(DOWNLOAD_ZOOMTODATA.get());
371
372        try {
373            tpDownloadAreaSelectors.setSelectedIndex(DOWNLOAD_TAB.get());
374        } catch (IndexOutOfBoundsException e) {
375            Logging.trace(e);
376            tpDownloadAreaSelectors.setSelectedIndex(0);
377        }
378
379        downloadSourcesTab.getAllPanels().forEach(AbstractDownloadSourcePanel::restoreSettings);
380        downloadSourcesTab.setSelected(DOWNLOAD_SOURCE_TAB.get());
381
382        if (MainApplication.isDisplayingMapView()) {
383            MapView mv = MainApplication.getMap().mapView;
384            currentBounds = new Bounds(
385                    mv.getLatLon(0, mv.getHeight()),
386                    mv.getLatLon(mv.getWidth(), 0)
387            );
388            boundingBoxChanged(currentBounds, null);
389        } else {
390            Bounds bounds = getSavedDownloadBounds();
391            if (bounds != null) {
392                currentBounds = bounds;
393                boundingBoxChanged(currentBounds, null);
394            }
395        }
396    }
397
398    /**
399     * Returns the previously saved bounding box from preferences.
400     * @return The bounding box saved in preferences if any, {@code null} otherwise.
401     * @since 6509
402     */
403    public static Bounds getSavedDownloadBounds() {
404        String value = Config.getPref().get("osm-download.bounds");
405        if (!value.isEmpty()) {
406            try {
407                return new Bounds(value, ";");
408            } catch (IllegalArgumentException e) {
409                Logging.warn(e);
410            }
411        }
412        return null;
413    }
414
415    /**
416     * Automatically opens the download dialog, if autorun is enabled.
417     * @see #isAutorunEnabled
418     */
419    public static void autostartIfNeeded() {
420        if (isAutorunEnabled()) {
421            MainApplication.getMenu().download.actionPerformed(null);
422        }
423    }
424
425    /**
426     * Returns an {@link Optional} of the currently selected download area.
427     * @return An {@link Optional} of the currently selected download area.
428     * @since 12574 Return type changed to optional
429     */
430    public Optional<Bounds> getSelectedDownloadArea() {
431        return Optional.ofNullable(currentBounds);
432    }
433
434    @Override
435    public void setVisible(boolean visible) {
436        if (visible) {
437            btnDownloadNewLayer.setVisible(
438                    !MainApplication.getLayerManager().getLayersOfType(OsmDataLayer.class).isEmpty());
439            new WindowGeometry(
440                    getClass().getName() + ".geometry",
441                    WindowGeometry.centerInWindow(
442                            getParent(),
443                            new Dimension(1000, 600)
444                    )
445            ).applySafe(this);
446        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
447            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
448        }
449        super.setVisible(visible);
450    }
451
452    /**
453     * Replies true if the dialog was canceled
454     *
455     * @return true if the dialog was canceled
456     */
457    public boolean isCanceled() {
458        return canceled;
459    }
460
461    /**
462     * Gets the global settings of the download dialog.
463     * @param newLayer The flag defining if a new layer must be created for the downloaded data.
464     * @return The {@link DownloadSettings} object that describes the current state of
465     * the download dialog.
466     */
467    public DownloadSettings getDownloadSettings(boolean newLayer) {
468        return new DownloadSettings(currentBounds, newLayer, isZoomToDownloadedDataRequired());
469    }
470
471    protected void setCanceled(boolean canceled) {
472        this.canceled = canceled;
473    }
474
475    /**
476     * Adds the download source to the download sources tab.
477     * @param downloadSource The download source to be added.
478     * @param <T> The type of the download data.
479     */
480    protected <T> void addNewDownloadSourceTab(DownloadSource<T> downloadSource) {
481        downloadSourcesTab.addPanel(downloadSource.createPanel(this));
482    }
483
484    /**
485     * Creates listener that removes/adds download sources from/to {@code downloadSourcesTab}
486     * depending on the current mode.
487     * @return The expert mode listener.
488     */
489    private ExpertToggleAction.ExpertModeChangeListener getExpertModeListenerForDownloadSources() {
490        return downloadSourcesTab::updateExpert;
491    }
492
493    /**
494     * Creates a listener that reacts on tab switches for {@code downloadSourcesTab} in order
495     * to adjust proper division of the dialog according to user saved preferences or minimal size
496     * of the panel.
497     * @return A listener to adjust dialog division.
498     */
499    private ChangeListener getDownloadSourceTabChangeListener() {
500        return ec -> downloadSourcesTab.getSelectedPanel().ifPresent(
501                panel -> dialogSplit.setPolicy(panel.getSizingPolicy()));
502    }
503
504    /**
505     * Action that is executed when the cancel button is pressed.
506     */
507    class CancelAction extends AbstractAction {
508        CancelAction() {
509            putValue(NAME, tr("Cancel"));
510            new ImageProvider("cancel").getResource().attachImageIcon(this);
511            putValue(SHORT_DESCRIPTION, tr("Click to close the dialog and to abort downloading"));
512        }
513
514        /**
515         * Cancels the download
516         */
517        public void run() {
518            rememberSettings();
519            setCanceled(true);
520            setVisible(false);
521        }
522
523        @Override
524        public void actionPerformed(ActionEvent e) {
525            Optional<AbstractDownloadSourcePanel<?>> panel = downloadSourcesTab.getSelectedPanel();
526            run();
527            panel.ifPresent(AbstractDownloadSourcePanel::checkCancel);
528        }
529    }
530
531    /**
532     * Action that is executed when the download button is pressed.
533     */
534    class DownloadAction extends AbstractAction {
535        final boolean newLayer;
536        DownloadAction(boolean newLayer) {
537            this.newLayer = newLayer;
538            if (!newLayer) {
539                putValue(NAME, tr("Download"));
540                putValue(SHORT_DESCRIPTION, tr("Click to download the currently selected area"));
541                new ImageProvider("download").getResource().attachImageIcon(this);
542            } else {
543                putValue(NAME, tr("Download as new layer"));
544                putValue(SHORT_DESCRIPTION, tr("Click to download the currently selected area into a new data layer"));
545                new ImageProvider("download_new_layer").getResource().attachImageIcon(this);
546            }
547            setEnabled(!NetworkManager.isOffline(OnlineResource.OSM_API));
548        }
549
550        /**
551         * Starts the download and closes the dialog, if all requirements for the current download source are met.
552         * Otherwise the download is not started and the dialog remains visible.
553         */
554        public void run() {
555            rememberSettings();
556            downloadSourcesTab.getSelectedPanel().ifPresent(panel -> {
557                DownloadSettings downloadSettings = getDownloadSettings(newLayer);
558                if (panel.checkDownload(downloadSettings)) {
559                    setCanceled(false);
560                    setVisible(false);
561                    panel.triggerDownload(downloadSettings);
562                }
563            });
564        }
565
566        @Override
567        public void actionPerformed(ActionEvent e) {
568            run();
569        }
570    }
571
572    class WindowEventHandler extends WindowAdapter {
573        @Override
574        public void windowClosing(WindowEvent e) {
575            new CancelAction().run();
576        }
577
578        @Override
579        public void windowActivated(WindowEvent e) {
580            btnDownload.requestFocusInWindow();
581        }
582    }
583
584    /**
585     * A special tabbed pane for {@link AbstractDownloadSourcePanel}s
586     * @author Michael Zangl
587     * @since 12706
588     */
589    private class DownloadSourceTabs extends JTabbedPane implements DownloadSourceListener {
590        private final List<AbstractDownloadSourcePanel<?>> allPanels = new ArrayList<>();
591
592        DownloadSourceTabs() {
593            downloadSources.forEach(this::downloadSourceAdded);
594            downloadSourcesListeners.addListener(this);
595        }
596
597        List<AbstractDownloadSourcePanel<?>> getAllPanels() {
598            return allPanels;
599        }
600
601        List<AbstractDownloadSourcePanel<?>> getVisiblePanels() {
602            return IntStream.range(0, getTabCount())
603                    .mapToObj(this::getComponentAt)
604                    .map(p -> (AbstractDownloadSourcePanel<?>) p)
605                    .collect(Collectors.toList());
606        }
607
608        void setSelected(String simpleName) {
609            getVisiblePanels().stream()
610                .filter(panel -> simpleName.equals(panel.getSimpleName()))
611                .findFirst()
612                .ifPresent(this::setSelectedComponent);
613        }
614
615        void updateExpert(boolean isExpert) {
616            updateTabs();
617        }
618
619        void addPanel(AbstractDownloadSourcePanel<?> panel) {
620            allPanels.add(panel);
621            updateTabs();
622        }
623
624        private void updateTabs() {
625            // Not the best performance, but we don't do it often
626            removeAll();
627
628            boolean isExpert = ExpertToggleAction.isExpert();
629            allPanels.stream()
630                .filter(panel -> isExpert || !panel.getDownloadSource().onlyExpert())
631                .forEach(panel -> addTab(panel.getDownloadSource().getLabel(), panel.getIcon(), panel));
632        }
633
634        Optional<AbstractDownloadSourcePanel<?>> getSelectedPanel() {
635            return Optional.ofNullable((AbstractDownloadSourcePanel<?>) getSelectedComponent());
636        }
637
638        @Override
639        public void insertTab(String title, Icon icon, Component component, String tip, int index) {
640            if (!(component instanceof AbstractDownloadSourcePanel)) {
641                throw new IllegalArgumentException("Can only add AbstractDownloadSourcePanels");
642            }
643            super.insertTab(title, icon, component, tip, index);
644        }
645
646        @Override
647        public void downloadSourceAdded(DownloadSource<?> source) {
648            addPanel(source.createPanel(DownloadDialog.this));
649        }
650    }
651
652    /**
653     * A special split pane that acts according to a {@link DownloadSourceSizingPolicy}
654     *
655     * It attempts to size the top tab content correctly.
656     *
657     * @author Michael Zangl
658     * @since 12705
659     */
660    private static class DownloadDialogSplitPane extends JSplitPane {
661        private DownloadSourceSizingPolicy policy;
662        private final JTabbedPane topComponent;
663        /**
664         * If the height was explicitly set by the user.
665         */
666        private boolean heightAdjustedExplicitly;
667
668        DownloadDialogSplitPane(JTabbedPane newTopComponent, Component newBottomComponent) {
669            super(VERTICAL_SPLIT, newTopComponent, newBottomComponent);
670            this.topComponent = newTopComponent;
671
672            addComponentListener(new ComponentAdapter() {
673                @Override
674                public void componentResized(ComponentEvent e) {
675                    // doLayout is called automatically when the component size decreases
676                    // This seems to be the only way to call doLayout when the component size increases
677                    // We need this since we sometimes want to increase the top component size.
678                    revalidate();
679                }
680            });
681
682            addPropertyChangeListener(DIVIDER_LOCATION_PROPERTY, e -> heightAdjustedExplicitly = true);
683        }
684
685        public void setPolicy(DownloadSourceSizingPolicy policy) {
686            this.policy = policy;
687
688            super.setDividerLocation(policy.getComponentHeight() + computeOffset());
689            setDividerSize(policy.isHeightAdjustable() ? 10 : 0);
690            setEnabled(policy.isHeightAdjustable());
691        }
692
693        @Override
694        public void doLayout() {
695            // We need to force this height before the layout manager is run.
696            // We cannot do this in the setDividerLocation, since the offset cannot be computed there.
697            int offset = computeOffset();
698            if (policy.isHeightAdjustable() && heightAdjustedExplicitly) {
699                policy.storeHeight(Math.max(getDividerLocation() - offset, 0));
700            }
701            // At least 30 pixel for map, if we have enough space
702            int maxValidDividerLocation = getHeight() > 150 ? getHeight() - 40 : getHeight();
703
704            super.setDividerLocation(Math.min(policy.getComponentHeight() + offset, maxValidDividerLocation));
705            super.doLayout();
706            // Order is important (set this after setDividerLocation/doLayout called the listener)
707            this.heightAdjustedExplicitly = false;
708        }
709
710        /**
711         * @return The difference between the content height and the divider location
712         */
713        private int computeOffset() {
714            Component selectedComponent = topComponent.getSelectedComponent();
715            return topComponent.getHeight() - (selectedComponent == null ? 0 : selectedComponent.getHeight());
716        }
717    }
718}