001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Component;
007import java.awt.Dimension;
008import java.awt.Frame;
009import java.awt.GridBagConstraints;
010import java.awt.GridBagLayout;
011import java.awt.Insets;
012import java.awt.event.ActionEvent;
013import java.awt.event.KeyEvent;
014import java.util.ArrayList;
015import java.util.Arrays;
016import java.util.Collections;
017import java.util.HashSet;
018import java.util.List;
019import java.util.Set;
020
021import javax.swing.AbstractAction;
022import javax.swing.Action;
023import javax.swing.Icon;
024import javax.swing.JButton;
025import javax.swing.JDialog;
026import javax.swing.JLabel;
027import javax.swing.JOptionPane;
028import javax.swing.JPanel;
029import javax.swing.JScrollBar;
030import javax.swing.JScrollPane;
031import javax.swing.KeyStroke;
032import javax.swing.UIManager;
033
034import org.openstreetmap.josm.gui.help.HelpBrowser;
035import org.openstreetmap.josm.gui.help.HelpUtil;
036import org.openstreetmap.josm.gui.util.GuiHelper;
037import org.openstreetmap.josm.gui.util.WindowGeometry;
038import org.openstreetmap.josm.gui.widgets.JMultilineLabel;
039import org.openstreetmap.josm.io.NetworkManager;
040import org.openstreetmap.josm.io.OnlineResource;
041import org.openstreetmap.josm.tools.GBC;
042import org.openstreetmap.josm.tools.ImageProvider;
043import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
044import org.openstreetmap.josm.tools.InputMapUtils;
045import org.openstreetmap.josm.tools.Logging;
046import org.openstreetmap.josm.tools.Utils;
047
048/**
049 * General configurable dialog window.
050 *
051 * If dialog is modal, you can use {@link #getValue()} to retrieve the
052 * button index. Note that the user can close the dialog
053 * by other means. This is usually equivalent to cancel action.
054 *
055 * For non-modal dialogs, {@link #buttonAction(int, ActionEvent)} can be overridden.
056 *
057 * There are various options, see below.
058 *
059 * Note: The button indices are counted from 1 and upwards.
060 * So for {@link #getValue()}, {@link #setDefaultButton(int)} and
061 * {@link #setCancelButton} the first button has index 1.
062 *
063 * Simple example:
064 * <pre>
065 *  ExtendedDialog ed = new ExtendedDialog(
066 *          MainApplication.getMainFrame(), tr("Dialog Title"),
067 *          new String[] {tr("Ok"), tr("Cancel")});
068 *  ed.setButtonIcons(new String[] {"ok", "cancel"});   // optional
069 *  ed.setIcon(JOptionPane.WARNING_MESSAGE);            // optional
070 *  ed.setContent(tr("Really proceed? Interesting things may happen..."));
071 *  ed.showDialog();
072 *  if (ed.getValue() == 1) { // user clicked first button "Ok"
073 *      // proceed...
074 *  }
075 * </pre>
076 */
077public class ExtendedDialog extends JDialog implements IExtendedDialog {
078    private final boolean disposeOnClose;
079    private volatile int result;
080    public static final int DialogClosedOtherwise = 0;
081    private boolean toggleable;
082    private String rememberSizePref = "";
083    private transient WindowGeometry defaultWindowGeometry;
084    private String togglePref = "";
085    private int toggleValue = -1;
086    private ConditionalOptionPaneUtil.MessagePanel togglePanel;
087    private Component parent;
088    private Component content;
089    private final String[] bTexts;
090    private String[] bToolTipTexts;
091    private transient Icon[] bIcons;
092    private Set<Integer> cancelButtonIdx = Collections.emptySet();
093    private int defaultButtonIdx = 1;
094    protected JButton defaultButton;
095    private transient Icon icon;
096    private boolean modal;
097    private boolean focusOnDefaultButton;
098
099    /** true, if the dialog should include a help button */
100    private boolean showHelpButton;
101    /** the help topic */
102    private String helpTopic;
103
104    /**
105     * set to true if the content of the extended dialog should
106     * be placed in a {@link JScrollPane}
107     */
108    private boolean placeContentInScrollPane;
109
110    // For easy access when inherited
111    protected transient Insets contentInsets = new Insets(10, 5, 0, 5);
112    protected transient List<JButton> buttons = new ArrayList<>();
113
114    /**
115     * This method sets up the most basic options for the dialog. Add more
116     * advanced features with dedicated methods.
117     * Possible features:
118     * <ul>
119     *   <li><code>setButtonIcons</code></li>
120     *   <li><code>setContent</code></li>
121     *   <li><code>toggleEnable</code></li>
122     *   <li><code>toggleDisable</code></li>
123     *   <li><code>setToggleCheckboxText</code></li>
124     *   <li><code>setRememberWindowGeometry</code></li>
125     * </ul>
126     *
127     * When done, call <code>showDialog</code> to display it. You can receive
128     * the user's choice using <code>getValue</code>. Have a look at this function
129     * for possible return values.
130     *
131     * @param parent       The parent element that will be used for position and maximum size
132     * @param title        The text that will be shown in the window titlebar
133     * @param buttonTexts  String Array of the text that will appear on the buttons. The first button is the default one.
134     */
135    public ExtendedDialog(Component parent, String title, String... buttonTexts) {
136        this(parent, title, buttonTexts, true, true);
137    }
138
139    /**
140     * Same as above but lets you define if the dialog should be modal.
141     * @param parent The parent element that will be used for position and maximum size
142     * @param title The text that will be shown in the window titlebar
143     * @param buttonTexts String Array of the text that will appear on the buttons. The first button is the default one.
144     * @param modal Set it to {@code true} if you want the dialog to be modal
145     */
146    public ExtendedDialog(Component parent, String title, String[] buttonTexts, boolean modal) {
147        this(parent, title, buttonTexts, modal, true);
148    }
149
150    /**
151     * Same as above but lets you define if the dialog should be disposed on close.
152     * @param parent The parent element that will be used for position and maximum size
153     * @param title The text that will be shown in the window titlebar
154     * @param buttonTexts String Array of the text that will appear on the buttons. The first button is the default one.
155     * @param modal Set it to {@code true} if you want the dialog to be modal
156     * @param disposeOnClose whether to call {@link #dispose} when closing the dialog
157     */
158    public ExtendedDialog(Component parent, String title, String[] buttonTexts, boolean modal, boolean disposeOnClose) {
159        super(searchRealParent(parent), title, modal ? ModalityType.DOCUMENT_MODAL : ModalityType.MODELESS);
160        this.parent = parent;
161        this.modal = modal;
162        bTexts = Utils.copyArray(buttonTexts);
163        if (disposeOnClose) {
164            setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
165        }
166        this.disposeOnClose = disposeOnClose;
167    }
168
169    private static Frame searchRealParent(Component parent) {
170        if (parent == null) {
171            return null;
172        } else {
173            return GuiHelper.getFrameForComponent(parent);
174        }
175    }
176
177    @Override
178    public ExtendedDialog setButtonIcons(Icon... buttonIcons) {
179        this.bIcons = Utils.copyArray(buttonIcons);
180        return this;
181    }
182
183    @Override
184    public ExtendedDialog setButtonIcons(String... buttonIcons) {
185        bIcons = new Icon[buttonIcons.length];
186        for (int i = 0; i < buttonIcons.length; ++i) {
187            bIcons[i] = ImageProvider.get(buttonIcons[i], ImageSizes.LARGEICON);
188        }
189        return this;
190    }
191
192    @Override
193    public ExtendedDialog setToolTipTexts(String... toolTipTexts) {
194        this.bToolTipTexts = Utils.copyArray(toolTipTexts);
195        return this;
196    }
197
198    @Override
199    public ExtendedDialog setContent(Component content) {
200        return setContent(content, true);
201    }
202
203    @Override
204    public ExtendedDialog setContent(Component content, boolean placeContentInScrollPane) {
205        this.content = content;
206        this.placeContentInScrollPane = placeContentInScrollPane;
207        return this;
208    }
209
210    @Override
211    public ExtendedDialog setContent(String message) {
212        return setContent(string2label(message), false);
213    }
214
215    @Override
216    public ExtendedDialog setIcon(Icon icon) {
217        this.icon = icon;
218        return this;
219    }
220
221    @Override
222    public ExtendedDialog setIcon(int messageType) {
223        switch (messageType) {
224            case JOptionPane.ERROR_MESSAGE:
225                return setIcon(UIManager.getIcon("OptionPane.errorIcon"));
226            case JOptionPane.INFORMATION_MESSAGE:
227                return setIcon(UIManager.getIcon("OptionPane.informationIcon"));
228            case JOptionPane.WARNING_MESSAGE:
229                return setIcon(UIManager.getIcon("OptionPane.warningIcon"));
230            case JOptionPane.QUESTION_MESSAGE:
231                return setIcon(UIManager.getIcon("OptionPane.questionIcon"));
232            case JOptionPane.PLAIN_MESSAGE:
233                return setIcon(null);
234            default:
235                throw new IllegalArgumentException("Unknown message type!");
236        }
237    }
238
239    @Override
240    public ExtendedDialog showDialog() {
241        // Check if the user has set the dialog to not be shown again
242        if (toggleCheckState()) {
243            result = toggleValue;
244            return this;
245        }
246
247        setupDialog();
248        if (defaultButton != null) {
249            getRootPane().setDefaultButton(defaultButton);
250        }
251        // Don't focus the "do not show this again" check box, but the default button.
252        if (toggleable || focusOnDefaultButton) {
253            requestFocusToDefaultButton();
254        }
255        setVisible(true);
256        toggleSaveState();
257        return this;
258    }
259
260    @Override
261    public int getValue() {
262        return result;
263    }
264
265    private boolean setupDone;
266
267    @Override
268    public void setupDialog() {
269        if (setupDone)
270            return;
271        setupDone = true;
272
273        setupEscListener();
274
275        JButton button;
276        JPanel buttonsPanel = new JPanel(new GridBagLayout());
277
278        for (int i = 0; i < bTexts.length; i++) {
279            button = new JButton(createButtonAction(i));
280            if (i == defaultButtonIdx-1) {
281                defaultButton = button;
282            }
283            if (bIcons != null && bIcons[i] != null) {
284                button.setIcon(bIcons[i]);
285            }
286            if (bToolTipTexts != null && i < bToolTipTexts.length && bToolTipTexts[i] != null) {
287                button.setToolTipText(bToolTipTexts[i]);
288            }
289
290            buttonsPanel.add(button, GBC.std().insets(2, 2, 2, 2));
291            buttons.add(button);
292        }
293        if (showHelpButton) {
294            buttonsPanel.add(new JButton(new HelpAction()), GBC.std().insets(2, 2, 2, 2));
295            HelpUtil.setHelpContext(getRootPane(), helpTopic);
296        }
297
298        JPanel cp = new JPanel(new GridBagLayout());
299
300        GridBagConstraints gc = new GridBagConstraints();
301        gc.gridx = 0;
302        int y = 0;
303        gc.gridy = y++;
304        gc.weightx = 0.0;
305        gc.weighty = 0.0;
306
307        if (icon != null) {
308            JLabel iconLbl = new JLabel(icon);
309            gc.insets = new Insets(10, 10, 10, 10);
310            gc.anchor = GridBagConstraints.NORTH;
311            gc.weighty = 1.0;
312            cp.add(iconLbl, gc);
313            gc.anchor = GridBagConstraints.CENTER;
314            gc.gridx = 1;
315        }
316
317        gc.fill = GridBagConstraints.BOTH;
318        gc.insets = contentInsets;
319        gc.weightx = 1.0;
320        gc.weighty = 1.0;
321        cp.add(content, gc);
322
323        gc.fill = GridBagConstraints.NONE;
324        gc.gridwidth = GridBagConstraints.REMAINDER;
325        gc.weightx = 0.0;
326        gc.weighty = 0.0;
327
328        if (toggleable) {
329            togglePanel = new ConditionalOptionPaneUtil.MessagePanel(null, ConditionalOptionPaneUtil.isInBulkOperation(togglePref));
330            gc.gridx = icon != null ? 1 : 0;
331            gc.gridy = y++;
332            gc.anchor = GridBagConstraints.LINE_START;
333            gc.insets = new Insets(5, contentInsets.left, 5, contentInsets.right);
334            cp.add(togglePanel, gc);
335        }
336
337        gc.gridy = y;
338        gc.anchor = GridBagConstraints.CENTER;
339            gc.insets = new Insets(5, 5, 5, 5);
340        cp.add(buttonsPanel, gc);
341        if (placeContentInScrollPane) {
342            JScrollPane pane = new JScrollPane(cp);
343            GuiHelper.setDefaultIncrement(pane);
344            pane.setBorder(null);
345            setContentPane(pane);
346        } else {
347            setContentPane(cp);
348        }
349        pack();
350
351        // Try to make it not larger than the parent window or at least not larger than 2/3 of the screen
352        Dimension d = getSize();
353        Dimension x = findMaxDialogSize();
354
355        boolean limitedInWidth = d.width > x.width;
356        boolean limitedInHeight = d.height > x.height;
357
358        if (x.width > 0 && d.width > x.width) {
359            d.width = x.width;
360        }
361        if (x.height > 0 && d.height > x.height) {
362            d.height = x.height;
363        }
364
365        // We have a vertical scrollbar and enough space to prevent a horizontal one
366        if (!limitedInWidth && limitedInHeight) {
367            d.width += new JScrollBar().getPreferredSize().width;
368        }
369
370        setSize(d);
371        setLocationRelativeTo(parent);
372    }
373
374    protected Action createButtonAction(final int i) {
375        return new AbstractAction(bTexts[i]) {
376            @Override
377            public void actionPerformed(ActionEvent evt) {
378                buttonAction(i, evt);
379            }
380        };
381    }
382
383    /**
384     * This gets performed whenever a button is clicked or activated
385     * @param buttonIndex the button index (first index is 0)
386     * @param evt the button event
387     */
388    protected void buttonAction(int buttonIndex, ActionEvent evt) {
389        result = buttonIndex+1;
390        setVisible(false);
391    }
392
393    /**
394     * Tries to find a good value of how large the dialog should be
395     * @return Dimension Size of the parent component if visible or 2/3 of screen size if not available or hidden
396     */
397    protected Dimension findMaxDialogSize() {
398        Dimension screenSize = GuiHelper.getScreenSize();
399        Dimension x = new Dimension(screenSize.width*2/3, screenSize.height*2/3);
400        if (parent != null && parent.isVisible()) {
401            x = GuiHelper.getFrameForComponent(parent).getSize();
402        }
403        return x;
404    }
405
406    /**
407     * Makes the dialog listen to ESC keypressed
408     */
409    private void setupEscListener() {
410        Action actionListener = new AbstractAction() {
411            @Override
412            public void actionPerformed(ActionEvent actionEvent) {
413                // 0 means that the dialog has been closed otherwise.
414                // We need to set it to zero again, in case the dialog has been re-used
415                // and the result differs from its default value
416                result = ExtendedDialog.DialogClosedOtherwise;
417                if (Logging.isDebugEnabled()) {
418                    Logging.debug("{0} ESC action performed ({1}) from {2}",
419                            getClass().getName(), actionEvent, new Exception().getStackTrace()[1]);
420                }
421                setVisible(false);
422            }
423        };
424
425        InputMapUtils.addEscapeAction(getRootPane(), actionListener);
426    }
427
428    protected final void rememberWindowGeometry(WindowGeometry geometry) {
429        if (geometry != null) {
430            geometry.remember(rememberSizePref);
431        }
432    }
433
434    protected final WindowGeometry initWindowGeometry() {
435        return new WindowGeometry(rememberSizePref, defaultWindowGeometry);
436    }
437
438    /**
439     * Override setVisible to be able to save the window geometry if required
440     */
441    @Override
442    public void setVisible(boolean visible) {
443        if (visible) {
444            repaint();
445        }
446
447        if (Logging.isDebugEnabled()) {
448            Logging.debug(getClass().getName()+".setVisible("+visible+") from "+new Exception().getStackTrace()[1]);
449        }
450
451        // Ensure all required variables are available
452        if (!rememberSizePref.isEmpty() && defaultWindowGeometry != null) {
453            if (visible) {
454                initWindowGeometry().applySafe(this);
455            } else if (isShowing()) { // should fix #6438, #6981, #8295
456                rememberWindowGeometry(new WindowGeometry(this));
457            }
458        }
459        super.setVisible(visible);
460
461        if (!visible && disposeOnClose) {
462            dispose();
463        }
464    }
465
466    @Override
467    public ExtendedDialog setRememberWindowGeometry(String pref, WindowGeometry wg) {
468        rememberSizePref = pref == null ? "" : pref;
469        defaultWindowGeometry = wg;
470        return this;
471    }
472
473    @Override
474    public ExtendedDialog toggleEnable(String togglePref) {
475        if (!modal) {
476            throw new IllegalStateException();
477        }
478        this.toggleable = true;
479        this.togglePref = togglePref;
480        return this;
481    }
482
483    @Override
484    public ExtendedDialog setDefaultButton(int defaultButtonIdx) {
485        this.defaultButtonIdx = defaultButtonIdx;
486        return this;
487    }
488
489    @Override
490    public ExtendedDialog setCancelButton(Integer... cancelButtonIdx) {
491        this.cancelButtonIdx = new HashSet<>(Arrays.<Integer>asList(cancelButtonIdx));
492        return this;
493    }
494
495    @Override
496    public void setFocusOnDefaultButton(boolean focus) {
497        focusOnDefaultButton = focus;
498    }
499
500    private void requestFocusToDefaultButton() {
501        if (defaultButton != null) {
502            GuiHelper.runInEDT(defaultButton::requestFocusInWindow);
503        }
504    }
505
506    @Override
507    public final boolean toggleCheckState() {
508        toggleable = togglePref != null && !togglePref.isEmpty();
509        toggleValue = ConditionalOptionPaneUtil.getDialogReturnValue(togglePref);
510        return toggleable && toggleValue != -1;
511    }
512
513    /**
514     * This function checks the state of the "Do not show again" checkbox and
515     * writes the corresponding pref.
516     */
517    protected void toggleSaveState() {
518        if (!toggleable ||
519                togglePanel == null ||
520                cancelButtonIdx.contains(result) ||
521                result == ExtendedDialog.DialogClosedOtherwise)
522            return;
523        togglePanel.getNotShowAgain().store(togglePref, result);
524    }
525
526    /**
527     * Convenience function that converts a given string into a JMultilineLabel
528     * @param msg the message to display
529     * @return JMultilineLabel displaying {@code msg}
530     */
531    private static JMultilineLabel string2label(String msg) {
532        JMultilineLabel lbl = new JMultilineLabel(msg);
533        // Make it not wider than 1/2 of the screen
534        Dimension screenSize = GuiHelper.getScreenSize();
535        lbl.setMaxWidth(screenSize.width/2);
536        // Disable default Enter key binding to allow dialog's one (then enables to hit default button from here)
537        lbl.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), new Object());
538        return lbl;
539    }
540
541    @Override
542    public ExtendedDialog configureContextsensitiveHelp(String helpTopic, boolean showHelpButton) {
543        this.helpTopic = helpTopic;
544        this.showHelpButton = showHelpButton;
545        return this;
546    }
547
548    class HelpAction extends AbstractAction {
549        /**
550         * Constructs a new {@code HelpAction}.
551         */
552        HelpAction() {
553            putValue(SHORT_DESCRIPTION, tr("Show help information"));
554            putValue(NAME, tr("Help"));
555            new ImageProvider("help").getResource().attachImageIcon(this, true);
556            setEnabled(!NetworkManager.isOffline(OnlineResource.JOSM_WEBSITE));
557        }
558
559        @Override
560        public void actionPerformed(ActionEvent e) {
561            HelpBrowser.setUrlForHelpTopic(helpTopic);
562        }
563    }
564}