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     * Remove a download source from the download dialog
344     *
345     * @param downloadSource The download source to be removed.
346     * @return see {@link List#remove}
347     * @since 15542
348     */
349    public static boolean removeDownloadSource(DownloadSource<?> downloadSource) {
350        if (downloadSources.contains(downloadSource)) {
351            return downloadSources.remove(downloadSource);
352        }
353        return false;
354    }
355
356    /**
357     * Refreshes the tile sources.
358     * @since 6364
359     */
360    public final void refreshTileSources() {
361        if (slippyMapChooser != null) {
362            slippyMapChooser.refreshTileSources();
363        }
364    }
365
366    /**
367     * Remembers the current settings in the download dialog.
368     */
369    public void rememberSettings() {
370        DOWNLOAD_TAB.put(tpDownloadAreaSelectors.getSelectedIndex());
371        downloadSourcesTab.getAllPanels().forEach(AbstractDownloadSourcePanel::rememberSettings);
372        downloadSourcesTab.getSelectedPanel().ifPresent(panel -> DOWNLOAD_SOURCE_TAB.put(panel.getSimpleName()));
373        DOWNLOAD_ZOOMTODATA.put(cbZoomToDownloadedData.isSelected());
374        if (currentBounds != null) {
375            Config.getPref().put("osm-download.bounds", currentBounds.encodeAsString(";"));
376        }
377    }
378
379    /**
380     * Restores the previous settings in the download dialog.
381     */
382    public void restoreSettings() {
383        cbStartup.setSelected(isAutorunEnabled());
384        cbZoomToDownloadedData.setSelected(DOWNLOAD_ZOOMTODATA.get());
385
386        try {
387            tpDownloadAreaSelectors.setSelectedIndex(DOWNLOAD_TAB.get());
388        } catch (IndexOutOfBoundsException e) {
389            Logging.trace(e);
390            tpDownloadAreaSelectors.setSelectedIndex(0);
391        }
392
393        downloadSourcesTab.getAllPanels().forEach(AbstractDownloadSourcePanel::restoreSettings);
394        downloadSourcesTab.setSelected(DOWNLOAD_SOURCE_TAB.get());
395
396        if (MainApplication.isDisplayingMapView()) {
397            MapView mv = MainApplication.getMap().mapView;
398            currentBounds = new Bounds(
399                    mv.getLatLon(0, mv.getHeight()),
400                    mv.getLatLon(mv.getWidth(), 0)
401            );
402            boundingBoxChanged(currentBounds, null);
403        } else {
404            Bounds bounds = getSavedDownloadBounds();
405            if (bounds != null) {
406                currentBounds = bounds;
407                boundingBoxChanged(currentBounds, null);
408            }
409        }
410    }
411
412    /**
413     * Returns the previously saved bounding box from preferences.
414     * @return The bounding box saved in preferences if any, {@code null} otherwise.
415     * @since 6509
416     */
417    public static Bounds getSavedDownloadBounds() {
418        String value = Config.getPref().get("osm-download.bounds");
419        if (!value.isEmpty()) {
420            try {
421                return new Bounds(value, ";");
422            } catch (IllegalArgumentException e) {
423                Logging.warn(e);
424            }
425        }
426        return null;
427    }
428
429    /**
430     * Automatically opens the download dialog, if autorun is enabled.
431     * @see #isAutorunEnabled
432     */
433    public static void autostartIfNeeded() {
434        if (isAutorunEnabled()) {
435            MainApplication.getMenu().download.actionPerformed(null);
436        }
437    }
438
439    /**
440     * Returns an {@link Optional} of the currently selected download area.
441     * @return An {@link Optional} of the currently selected download area.
442     * @since 12574 Return type changed to optional
443     */
444    public Optional<Bounds> getSelectedDownloadArea() {
445        return Optional.ofNullable(currentBounds);
446    }
447
448    @Override
449    public void setVisible(boolean visible) {
450        if (visible) {
451            btnDownloadNewLayer.setEnabled(
452                    !MainApplication.getLayerManager().getLayersOfType(OsmDataLayer.class).isEmpty());
453            new WindowGeometry(
454                    getClass().getName() + ".geometry",
455                    WindowGeometry.centerInWindow(
456                            getParent(),
457                            new Dimension(1000, 600)
458                    )
459            ).applySafe(this);
460        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
461            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
462        }
463        super.setVisible(visible);
464    }
465
466    /**
467     * Replies true if the dialog was canceled
468     *
469     * @return true if the dialog was canceled
470     */
471    public boolean isCanceled() {
472        return canceled;
473    }
474
475    /**
476     * Gets the global settings of the download dialog.
477     * @param newLayer The flag defining if a new layer must be created for the downloaded data.
478     * @return The {@link DownloadSettings} object that describes the current state of
479     * the download dialog.
480     */
481    public DownloadSettings getDownloadSettings(boolean newLayer) {
482        return new DownloadSettings(currentBounds, newLayer, isZoomToDownloadedDataRequired());
483    }
484
485    protected void setCanceled(boolean canceled) {
486        this.canceled = canceled;
487    }
488
489    /**
490     * Adds the download source to the download sources tab.
491     * @param downloadSource The download source to be added.
492     * @param <T> The type of the download data.
493     */
494    protected <T> void addNewDownloadSourceTab(DownloadSource<T> downloadSource) {
495        downloadSourcesTab.addPanel(downloadSource.createPanel(this));
496    }
497
498    /**
499     * Creates listener that removes/adds download sources from/to {@code downloadSourcesTab}
500     * depending on the current mode.
501     * @return The expert mode listener.
502     */
503    private ExpertToggleAction.ExpertModeChangeListener getExpertModeListenerForDownloadSources() {
504        return downloadSourcesTab::updateExpert;
505    }
506
507    /**
508     * Creates a listener that reacts on tab switches for {@code downloadSourcesTab} in order
509     * to adjust proper division of the dialog according to user saved preferences or minimal size
510     * of the panel.
511     * @return A listener to adjust dialog division.
512     */
513    private ChangeListener getDownloadSourceTabChangeListener() {
514        return ec -> downloadSourcesTab.getSelectedPanel().ifPresent(
515                panel -> dialogSplit.setPolicy(panel.getSizingPolicy()));
516    }
517
518    /**
519     * Action that is executed when the cancel button is pressed.
520     */
521    class CancelAction extends AbstractAction {
522        CancelAction() {
523            putValue(NAME, tr("Cancel"));
524            new ImageProvider("cancel").getResource().attachImageIcon(this);
525            putValue(SHORT_DESCRIPTION, tr("Click to close the dialog and to abort downloading"));
526        }
527
528        /**
529         * Cancels the download
530         */
531        public void run() {
532            rememberSettings();
533            setCanceled(true);
534            setVisible(false);
535        }
536
537        @Override
538        public void actionPerformed(ActionEvent e) {
539            Optional<AbstractDownloadSourcePanel<?>> panel = downloadSourcesTab.getSelectedPanel();
540            run();
541            panel.ifPresent(AbstractDownloadSourcePanel::checkCancel);
542        }
543    }
544
545    /**
546     * Action that is executed when the download button is pressed.
547     */
548    class DownloadAction extends AbstractAction {
549        final boolean newLayer;
550        DownloadAction(boolean newLayer) {
551            this.newLayer = newLayer;
552            if (!newLayer) {
553                putValue(NAME, tr("Download"));
554                putValue(SHORT_DESCRIPTION, tr("Click to download the currently selected area"));
555                new ImageProvider("download").getResource().attachImageIcon(this);
556            } else {
557                putValue(NAME, tr("Download as new layer"));
558                putValue(SHORT_DESCRIPTION, tr("Click to download the currently selected area into a new data layer"));
559                new ImageProvider("download_new_layer").getResource().attachImageIcon(this);
560            }
561            setEnabled(!NetworkManager.isOffline(OnlineResource.OSM_API));
562        }
563
564        /**
565         * Starts the download and closes the dialog, if all requirements for the current download source are met.
566         * Otherwise the download is not started and the dialog remains visible.
567         */
568        public void run() {
569            rememberSettings();
570            downloadSourcesTab.getSelectedPanel().ifPresent(panel -> {
571                DownloadSettings downloadSettings = getDownloadSettings(newLayer);
572                if (panel.checkDownload(downloadSettings)) {
573                    setCanceled(false);
574                    setVisible(false);
575                    panel.triggerDownload(downloadSettings);
576                }
577            });
578        }
579
580        @Override
581        public void actionPerformed(ActionEvent e) {
582            run();
583        }
584    }
585
586    class WindowEventHandler extends WindowAdapter {
587        @Override
588        public void windowClosing(WindowEvent e) {
589            new CancelAction().run();
590        }
591
592        @Override
593        public void windowActivated(WindowEvent e) {
594            btnDownload.requestFocusInWindow();
595        }
596    }
597
598    /**
599     * A special tabbed pane for {@link AbstractDownloadSourcePanel}s
600     * @author Michael Zangl
601     * @since 12706
602     */
603    private class DownloadSourceTabs extends JTabbedPane implements DownloadSourceListener {
604        private final List<AbstractDownloadSourcePanel<?>> allPanels = new ArrayList<>();
605
606        DownloadSourceTabs() {
607            downloadSources.forEach(this::downloadSourceAdded);
608            downloadSourcesListeners.addListener(this);
609        }
610
611        List<AbstractDownloadSourcePanel<?>> getAllPanels() {
612            return allPanels;
613        }
614
615        List<AbstractDownloadSourcePanel<?>> getVisiblePanels() {
616            return IntStream.range(0, getTabCount())
617                    .mapToObj(this::getComponentAt)
618                    .map(p -> (AbstractDownloadSourcePanel<?>) p)
619                    .collect(Collectors.toList());
620        }
621
622        void setSelected(String simpleName) {
623            getVisiblePanels().stream()
624                .filter(panel -> simpleName.equals(panel.getSimpleName()))
625                .findFirst()
626                .ifPresent(this::setSelectedComponent);
627        }
628
629        void updateExpert(boolean isExpert) {
630            updateTabs();
631        }
632
633        void addPanel(AbstractDownloadSourcePanel<?> panel) {
634            allPanels.add(panel);
635            updateTabs();
636        }
637
638        private void updateTabs() {
639            // Not the best performance, but we don't do it often
640            removeAll();
641
642            boolean isExpert = ExpertToggleAction.isExpert();
643            allPanels.stream()
644                .filter(panel -> isExpert || !panel.getDownloadSource().onlyExpert())
645                .forEach(panel -> addTab(panel.getDownloadSource().getLabel(), panel.getIcon(), panel));
646        }
647
648        Optional<AbstractDownloadSourcePanel<?>> getSelectedPanel() {
649            return Optional.ofNullable((AbstractDownloadSourcePanel<?>) getSelectedComponent());
650        }
651
652        @Override
653        public void insertTab(String title, Icon icon, Component component, String tip, int index) {
654            if (!(component instanceof AbstractDownloadSourcePanel)) {
655                throw new IllegalArgumentException("Can only add AbstractDownloadSourcePanels");
656            }
657            super.insertTab(title, icon, component, tip, index);
658        }
659
660        @Override
661        public void downloadSourceAdded(DownloadSource<?> source) {
662            addPanel(source.createPanel(DownloadDialog.this));
663        }
664    }
665
666    /**
667     * A special split pane that acts according to a {@link DownloadSourceSizingPolicy}
668     *
669     * It attempts to size the top tab content correctly.
670     *
671     * @author Michael Zangl
672     * @since 12705
673     */
674    private static class DownloadDialogSplitPane extends JSplitPane {
675        private DownloadSourceSizingPolicy policy;
676        private final JTabbedPane topComponent;
677        /**
678         * If the height was explicitly set by the user.
679         */
680        private boolean heightAdjustedExplicitly;
681
682        DownloadDialogSplitPane(JTabbedPane newTopComponent, Component newBottomComponent) {
683            super(VERTICAL_SPLIT, newTopComponent, newBottomComponent);
684            this.topComponent = newTopComponent;
685
686            addComponentListener(new ComponentAdapter() {
687                @Override
688                public void componentResized(ComponentEvent e) {
689                    // doLayout is called automatically when the component size decreases
690                    // This seems to be the only way to call doLayout when the component size increases
691                    // We need this since we sometimes want to increase the top component size.
692                    revalidate();
693                }
694            });
695
696            addPropertyChangeListener(DIVIDER_LOCATION_PROPERTY, e -> heightAdjustedExplicitly = true);
697        }
698
699        public void setPolicy(DownloadSourceSizingPolicy policy) {
700            this.policy = policy;
701
702            super.setDividerLocation(policy.getComponentHeight() + computeOffset());
703            setDividerSize(policy.isHeightAdjustable() ? 10 : 0);
704            setEnabled(policy.isHeightAdjustable());
705        }
706
707        @Override
708        public void doLayout() {
709            // We need to force this height before the layout manager is run.
710            // We cannot do this in the setDividerLocation, since the offset cannot be computed there.
711            int offset = computeOffset();
712            if (policy.isHeightAdjustable() && heightAdjustedExplicitly) {
713                policy.storeHeight(Math.max(getDividerLocation() - offset, 0));
714            }
715            // At least 30 pixel for map, if we have enough space
716            int maxValidDividerLocation = getHeight() > 150 ? getHeight() - 40 : getHeight();
717
718            super.setDividerLocation(Math.min(policy.getComponentHeight() + offset, maxValidDividerLocation));
719            super.doLayout();
720            // Order is important (set this after setDividerLocation/doLayout called the listener)
721            this.heightAdjustedExplicitly = false;
722        }
723
724        /**
725         * @return The difference between the content height and the divider location
726         */
727        private int computeOffset() {
728            Component selectedComponent = topComponent.getSelectedComponent();
729            return topComponent.getHeight() - (selectedComponent == null ? 0 : selectedComponent.getHeight());
730        }
731    }
732}