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.GridBagConstraints;
009import java.awt.GridBagLayout;
010import java.awt.Insets;
011import java.awt.Toolkit;
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.List;
018
019import javax.swing.AbstractAction;
020import javax.swing.Action;
021import javax.swing.Icon;
022import javax.swing.JButton;
023import javax.swing.JComponent;
024import javax.swing.JDialog;
025import javax.swing.JLabel;
026import javax.swing.JOptionPane;
027import javax.swing.JPanel;
028import javax.swing.JScrollBar;
029import javax.swing.JScrollPane;
030import javax.swing.KeyStroke;
031import javax.swing.UIManager;
032
033import org.openstreetmap.josm.Main;
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.widgets.JMultilineLabel;
038import org.openstreetmap.josm.io.OnlineResource;
039import org.openstreetmap.josm.tools.GBC;
040import org.openstreetmap.josm.tools.ImageProvider;
041import org.openstreetmap.josm.tools.Utils;
042import org.openstreetmap.josm.tools.WindowGeometry;
043
044/**
045 * General configurable dialog window.
046 *
047 * If dialog is modal, you can use {@link #getValue()} to retrieve the
048 * button index. Note that the user can close the dialog
049 * by other means. This is usually equivalent to cancel action.
050 *
051 * For non-modal dialogs, {@link #buttonAction(int, ActionEvent)} can be overridden.
052 *
053 * There are various options, see below.
054 *
055 * Note: The button indices are counted from 1 and upwards.
056 * So for {@link #getValue()}, {@link #setDefaultButton(int)} and
057 * {@link #setCancelButton} the first button has index 1.
058 *
059 * Simple example:
060 * <pre>
061 *  ExtendedDialog ed = new ExtendedDialog(
062 *          Main.parent, tr("Dialog Title"),
063 *          new String[] {tr("Ok"), tr("Cancel")});
064 *  ed.setButtonIcons(new String[] {"ok", "cancel"});   // optional
065 *  ed.setIcon(JOptionPane.WARNING_MESSAGE);            // optional
066 *  ed.setContent(tr("Really proceed? Interesting things may happen..."));
067 *  ed.showDialog();
068 *  if (ed.getValue() == 1) { // user clicked first button "Ok"
069 *      // proceed...
070 *  }
071 * </pre>
072 */
073public class ExtendedDialog extends JDialog {
074    private final boolean disposeOnClose;
075    private int result = 0;
076    public static final int DialogClosedOtherwise = 0;
077    private boolean toggleable = false;
078    private String rememberSizePref = "";
079    private WindowGeometry defaultWindowGeometry = null;
080    private String togglePref = "";
081    private int toggleValue = -1;
082    private ConditionalOptionPaneUtil.MessagePanel togglePanel;
083    private Component parent;
084    private Component content;
085    private final String[] bTexts;
086    private String[] bToolTipTexts;
087    private Icon[] bIcons;
088    private List<Integer> cancelButtonIdx = Collections.emptyList();
089    private int defaultButtonIdx = 1;
090    protected JButton defaultButton = null;
091    private Icon icon;
092    private boolean modal;
093    private boolean focusOnDefaultButton = false;
094
095    /** true, if the dialog should include a help button */
096    private boolean showHelpButton;
097    /** the help topic */
098    private String helpTopic;
099
100    /**
101     * set to true if the content of the extended dialog should
102     * be placed in a {@link JScrollPane}
103     */
104    private boolean placeContentInScrollPane;
105
106    // For easy access when inherited
107    protected Insets contentInsets = new Insets(10,5,0,5);
108    protected List<JButton> buttons = new ArrayList<>();
109
110    /**
111     * This method sets up the most basic options for the dialog. Add more
112     * advanced features with dedicated methods.
113     * Possible features:
114     * <ul>
115     *   <li><code>setButtonIcons</code></li>
116     *   <li><code>setContent</code></li>
117     *   <li><code>toggleEnable</code></li>
118     *   <li><code>toggleDisable</code></li>
119     *   <li><code>setToggleCheckboxText</code></li>
120     *   <li><code>setRememberWindowGeometry</code></li>
121     * </ul>
122     *
123     * When done, call <code>showDialog</code> to display it. You can receive
124     * the user's choice using <code>getValue</code>. Have a look at this function
125     * for possible return values.
126     *
127     * @param parent       The parent element that will be used for position and maximum size
128     * @param title        The text that will be shown in the window titlebar
129     * @param buttonTexts  String Array of the text that will appear on the buttons. The first button is the default one.
130     */
131    public ExtendedDialog(Component parent, String title, String[] buttonTexts) {
132        this(parent, title, buttonTexts, true, true);
133    }
134
135    /**
136     * Same as above but lets you define if the dialog should be modal.
137     * @param parent The parent element that will be used for position and maximum size
138     * @param title The text that will be shown in the window titlebar
139     * @param buttonTexts String Array of the text that will appear on the buttons. The first button is the default one.
140     * @param modal Set it to {@code true} if you want the dialog to be modal
141     */
142    public ExtendedDialog(Component parent, String title, String[] buttonTexts, boolean modal) {
143        this(parent, title, buttonTexts, modal, true);
144    }
145
146    public ExtendedDialog(Component parent, String title, String[] buttonTexts, boolean modal, boolean disposeOnClose) {
147        super(JOptionPane.getFrameForComponent(parent), title, modal ? ModalityType.DOCUMENT_MODAL : ModalityType.MODELESS);
148        this.parent = parent;
149        this.modal = modal;
150        bTexts = Utils.copyArray(buttonTexts);
151        if (disposeOnClose) {
152            setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
153        }
154        this.disposeOnClose = disposeOnClose;
155    }
156
157    /**
158     * Allows decorating the buttons with icons.
159     * @param buttonIcons The button icons
160     * @return {@code this}
161     */
162    public ExtendedDialog setButtonIcons(Icon[] buttonIcons) {
163        this.bIcons = Utils.copyArray(buttonIcons);
164        return this;
165    }
166
167    /**
168     * Convenience method to provide image names instead of images.
169     * @param buttonIcons The button icon names
170     * @return {@code this}
171     */
172    public ExtendedDialog setButtonIcons(String[] buttonIcons) {
173        bIcons = new Icon[buttonIcons.length];
174        for (int i=0; i<buttonIcons.length; ++i) {
175            bIcons[i] = ImageProvider.get(buttonIcons[i]);
176        }
177        return this;
178    }
179
180    /**
181     * Allows decorating the buttons with tooltips. Expects a String array with
182     * translated tooltip texts.
183     *
184     * @param toolTipTexts the tool tip texts. Ignored, if null.
185     * @return {@code this}
186     */
187    public ExtendedDialog setToolTipTexts(String[] toolTipTexts) {
188        this.bToolTipTexts = Utils.copyArray(toolTipTexts);
189        return this;
190    }
191
192    /**
193     * Sets the content that will be displayed in the message dialog.
194     *
195     * Note that depending on your other settings more UI elements may appear.
196     * The content is played on top of the other elements though.
197     *
198     * @param content Any element that can be displayed in the message dialog
199     * @return {@code this}
200     */
201    public ExtendedDialog setContent(Component content) {
202        return setContent(content, true);
203    }
204
205    /**
206     * Sets the content that will be displayed in the message dialog.
207     *
208     * Note that depending on your other settings more UI elements may appear.
209     * The content is played on top of the other elements though.
210     *
211     * @param content Any element that can be displayed in the message dialog
212     * @param placeContentInScrollPane if true, places the content in a JScrollPane
213     * @return {@code this}
214     */
215    public ExtendedDialog setContent(Component content, boolean placeContentInScrollPane) {
216        this.content = content;
217        this.placeContentInScrollPane = placeContentInScrollPane;
218        return this;
219    }
220
221    /**
222     * Sets the message that will be displayed. The String will be automatically
223     * wrapped if it is too long.
224     *
225     * Note that depending on your other settings more UI elements may appear.
226     * The content is played on top of the other elements though.
227     *
228     * @param message The text that should be shown to the user
229     * @return {@code this}
230     */
231    public ExtendedDialog setContent(String message) {
232        return setContent(string2label(message), false);
233    }
234
235    /**
236     * Decorate the dialog with an icon that is shown on the left part of
237     * the window area. (Similar to how it is done in {@link JOptionPane})
238     * @param icon The icon to display
239     * @return {@code this}
240     */
241    public ExtendedDialog setIcon(Icon icon) {
242        this.icon = icon;
243        return this;
244    }
245
246    /**
247     * Convenience method to allow values that would be accepted by {@link JOptionPane} as messageType.
248     * @param messageType The {@link JOptionPane} messageType
249     * @return {@code this}
250     */
251    public ExtendedDialog setIcon(int messageType) {
252        switch (messageType) {
253            case JOptionPane.ERROR_MESSAGE:
254                return setIcon(UIManager.getIcon("OptionPane.errorIcon"));
255            case JOptionPane.INFORMATION_MESSAGE:
256                return setIcon(UIManager.getIcon("OptionPane.informationIcon"));
257            case JOptionPane.WARNING_MESSAGE:
258                return setIcon(UIManager.getIcon("OptionPane.warningIcon"));
259            case JOptionPane.QUESTION_MESSAGE:
260                return setIcon(UIManager.getIcon("OptionPane.questionIcon"));
261            case JOptionPane.PLAIN_MESSAGE:
262                return setIcon(null);
263            default:
264                throw new IllegalArgumentException("Unknown message type!");
265        }
266    }
267
268    /**
269     * Show the dialog to the user. Call this after you have set all options
270     * for the dialog. You can retrieve the result using {@link #getValue()}.
271     * @return {@code this}
272     */
273    public ExtendedDialog showDialog() {
274        // Check if the user has set the dialog to not be shown again
275        if (toggleCheckState()) {
276            result = toggleValue;
277            return this;
278        }
279
280        setupDialog();
281        if (defaultButton != null) {
282            getRootPane().setDefaultButton(defaultButton);
283        }
284        // Don't focus the "do not show this again" check box, but the default button.
285        if (toggleable || focusOnDefaultButton) {
286            requestFocusToDefaultButton();
287        }
288        setVisible(true);
289        toggleSaveState();
290        return this;
291    }
292
293    /**
294     * Retrieve the user choice after the dialog has been closed.
295     *
296     * @return <ul> <li>The selected button. The count starts with 1.</li>
297     *              <li>A return value of {@link #DialogClosedOtherwise} means the dialog has been closed otherwise.</li>
298     *         </ul>
299     */
300    public int getValue() {
301        return result;
302    }
303
304    private boolean setupDone = false;
305
306    /**
307     * This is called by {@link #showDialog()}.
308     * Only invoke from outside if you need to modify the contentPane
309     */
310    public void setupDialog() {
311        if (setupDone)
312            return;
313        setupDone = true;
314
315        setupEscListener();
316
317        JButton button;
318        JPanel buttonsPanel = new JPanel(new GridBagLayout());
319
320        for (int i=0; i < bTexts.length; i++) {
321            final int final_i = i;
322            Action action = new AbstractAction(bTexts[i]) {
323                @Override public void actionPerformed(ActionEvent evt) {
324                    buttonAction(final_i, evt);
325                }
326            };
327
328            button = new JButton(action);
329            if (i == defaultButtonIdx-1) {
330                defaultButton = button;
331            }
332            if(bIcons != null && bIcons[i] != null) {
333                button.setIcon(bIcons[i]);
334            }
335            if (bToolTipTexts != null && i < bToolTipTexts.length && bToolTipTexts[i] != null) {
336                button.setToolTipText(bToolTipTexts[i]);
337            }
338
339            buttonsPanel.add(button, GBC.std().insets(2,2,2,2));
340            buttons.add(button);
341        }
342        if (showHelpButton) {
343            buttonsPanel.add(new JButton(new HelpAction()), GBC.std().insets(2,2,2,2));
344            HelpUtil.setHelpContext(getRootPane(),helpTopic);
345        }
346
347        JPanel cp = new JPanel(new GridBagLayout());
348
349        GridBagConstraints gc = new GridBagConstraints();
350        gc.gridx = 0;
351        int y = 0;
352        gc.gridy = y++;
353        gc.weightx = 0.0;
354        gc.weighty = 0.0;
355
356        if (icon != null) {
357            JLabel iconLbl = new JLabel(icon);
358            gc.insets = new Insets(10,10,10,10);
359            gc.anchor = GridBagConstraints.NORTH;
360            gc.weighty = 1.0;
361            cp.add(iconLbl, gc);
362            gc.anchor = GridBagConstraints.CENTER;
363            gc.gridx = 1;
364        }
365
366        gc.fill = GridBagConstraints.BOTH;
367        gc.insets = contentInsets;
368        gc.weightx = 1.0;
369        gc.weighty = 1.0;
370        cp.add(content, gc);
371
372        gc.fill = GridBagConstraints.NONE;
373        gc.gridwidth = GridBagConstraints.REMAINDER;
374        gc.weightx = 0.0;
375        gc.weighty = 0.0;
376
377        if (toggleable) {
378            togglePanel = new ConditionalOptionPaneUtil.MessagePanel(null, ConditionalOptionPaneUtil.isInBulkOperation(togglePref));
379            gc.gridx = icon != null ? 1 : 0;
380            gc.gridy = y++;
381            gc.anchor = GridBagConstraints.LINE_START;
382            gc.insets = new Insets(5,contentInsets.left,5,contentInsets.right);
383            cp.add(togglePanel, gc);
384        }
385
386        gc.gridy = y++;
387        gc.anchor = GridBagConstraints.CENTER;
388            gc.insets = new Insets(5,5,5,5);
389        cp.add(buttonsPanel, gc);
390        if (placeContentInScrollPane) {
391            JScrollPane pane = new JScrollPane(cp);
392            pane.setBorder(null);
393            setContentPane(pane);
394        } else {
395            setContentPane(cp);
396        }
397        pack();
398
399        // Try to make it not larger than the parent window or at least not larger than 2/3 of the screen
400        Dimension d = getSize();
401        Dimension x = findMaxDialogSize();
402
403        boolean limitedInWidth = d.width > x.width;
404        boolean limitedInHeight = d.height > x.height;
405
406        if(x.width  > 0 && d.width  > x.width) {
407            d.width  = x.width;
408        }
409        if(x.height > 0 && d.height > x.height) {
410            d.height = x.height;
411        }
412
413        // We have a vertical scrollbar and enough space to prevent a horizontal one
414        if(!limitedInWidth && limitedInHeight) {
415            d.width += new JScrollBar().getPreferredSize().width;
416        }
417
418        setSize(d);
419        setLocationRelativeTo(parent);
420    }
421
422    /**
423     * This gets performed whenever a button is clicked or activated
424     * @param buttonIndex the button index (first index is 0)
425     * @param evt the button event
426     */
427    protected void buttonAction(int buttonIndex, ActionEvent evt) {
428        result = buttonIndex+1;
429        setVisible(false);
430    }
431
432    /**
433     * Tries to find a good value of how large the dialog should be
434     * @return Dimension Size of the parent Component or 2/3 of screen size if not available
435     */
436    protected Dimension findMaxDialogSize() {
437        Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
438        Dimension x = new Dimension(screenSize.width*2/3, screenSize.height*2/3);
439        if (parent != null) {
440            x = JOptionPane.getFrameForComponent(parent).getSize();
441        }
442        return x;
443    }
444
445    /**
446     * Makes the dialog listen to ESC keypressed
447     */
448    private void setupEscListener() {
449        Action actionListener = new AbstractAction() {
450            @Override
451            public void actionPerformed(ActionEvent actionEvent) {
452                // 0 means that the dialog has been closed otherwise.
453                // We need to set it to zero again, in case the dialog has been re-used
454                // and the result differs from its default value
455                result = ExtendedDialog.DialogClosedOtherwise;
456                setVisible(false);
457            }
458        };
459
460        getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
461            .put(KeyStroke.getKeyStroke("ESCAPE"), "ESCAPE");
462        getRootPane().getActionMap().put("ESCAPE", actionListener);
463    }
464
465    protected final void rememberWindowGeometry(WindowGeometry geometry) {
466        if (geometry != null) {
467            geometry.remember(rememberSizePref);
468        }
469    }
470
471    protected final WindowGeometry initWindowGeometry() {
472        return new WindowGeometry(rememberSizePref, defaultWindowGeometry);
473    }
474
475    /**
476     * Override setVisible to be able to save the window geometry if required
477     */
478    @Override
479    public void setVisible(boolean visible) {
480        if (visible) {
481            repaint();
482        }
483
484        // Ensure all required variables are available
485        if(rememberSizePref.length() != 0 && defaultWindowGeometry != null) {
486            if(visible) {
487                initWindowGeometry().applySafe(this);
488            } else if (isShowing()) { // should fix #6438, #6981, #8295
489                rememberWindowGeometry(new WindowGeometry(this));
490            }
491        }
492        super.setVisible(visible);
493
494        if (!visible && disposeOnClose) {
495            dispose();
496        }
497    }
498
499    /**
500     * Call this if you want the dialog to remember the geometry (size and position) set by the user.
501     * Set the pref to <code>null</code> or to an empty string to disable again.
502     * By default, it's disabled.
503     *
504     * Note: If you want to set the width of this dialog directly use the usual
505     * setSize, setPreferredSize, setMaxSize, setMinSize
506     *
507     * @param pref  The preference to save the dimension to
508     * @param wg    The default window geometry that should be used if no
509     *              existing preference is found (only takes effect if
510     *              <code>pref</code> is not null or empty
511     * @return {@code this}
512     */
513    public ExtendedDialog setRememberWindowGeometry(String pref, WindowGeometry wg) {
514        rememberSizePref = pref == null ? "" : pref;
515        defaultWindowGeometry = wg;
516        return this;
517    }
518
519    /**
520     * Calling this will offer the user a "Do not show again" checkbox for the
521     * dialog. Default is to not offer the choice; the dialog will be shown
522     * every time.
523     * Currently, this is not supported for non-modal dialogs.
524     * @param togglePref  The preference to save the checkbox state to
525     * @return {@code this}
526     */
527    public ExtendedDialog toggleEnable(String togglePref) {
528        if (!modal) {
529            throw new IllegalStateException();
530        }
531        this.toggleable = true;
532        this.togglePref = togglePref;
533        return this;
534    }
535
536    /**
537     * Call this if you "accidentally" called toggleEnable. This doesn't need
538     * to be called for every dialog, as it's the default anyway.
539     * @return {@code this}
540     */
541    public ExtendedDialog toggleDisable() {
542        this.toggleable = false;
543        return this;
544    }
545
546    /**
547     * Sets the button that will react to ENTER.
548     * @param defaultButtonIdx The button index (starts to 1)
549     * @return {@code this}
550     */
551    public ExtendedDialog setDefaultButton(int defaultButtonIdx) {
552        this.defaultButtonIdx = defaultButtonIdx;
553        return this;
554    }
555
556    /**
557     * Used in combination with toggle:
558     * If the user presses 'cancel' the toggle settings are ignored and not saved to the pref
559     * @param cancelButtonIdx index of the button that stands for cancel, accepts multiple values
560     * @return {@code this}
561     */
562    public ExtendedDialog setCancelButton(Integer... cancelButtonIdx) {
563        this.cancelButtonIdx = Arrays.<Integer>asList(cancelButtonIdx);
564        return this;
565    }
566
567    /**
568     * Makes default button request initial focus or not.
569     * @param focus {@code true} to make default button request initial focus
570     * @since 7407
571     */
572    public void setFocusOnDefaultButton(boolean focus) {
573        focusOnDefaultButton = focus;
574    }
575
576    private void requestFocusToDefaultButton() {
577        if (defaultButton != null) {
578            GuiHelper.runInEDT(new Runnable() {
579                @Override
580                public void run() {
581                    defaultButton.requestFocusInWindow();
582                }
583            });
584        }
585    }
586
587    /**
588     * This function returns true if the dialog has been set to "do not show again"
589     * @return true if dialog should not be shown again
590     */
591    public final boolean toggleCheckState() {
592        toggleable = togglePref != null && !togglePref.isEmpty();
593        toggleValue = ConditionalOptionPaneUtil.getDialogReturnValue(togglePref);
594        return toggleable && toggleValue != -1;
595    }
596
597    /**
598     * This function checks the state of the "Do not show again" checkbox and
599     * writes the corresponding pref.
600     */
601    private void toggleSaveState() {
602        if (!toggleable ||
603                togglePanel == null ||
604                cancelButtonIdx.contains(result) ||
605                result == ExtendedDialog.DialogClosedOtherwise)
606            return;
607        togglePanel.getNotShowAgain().store(togglePref, result);
608    }
609
610    /**
611     * Convenience function that converts a given string into a JMultilineLabel
612     * @param msg the message to display
613     * @return JMultilineLabel displaying {@code msg}
614     */
615    private static JMultilineLabel string2label(String msg) {
616        JMultilineLabel lbl = new JMultilineLabel(msg);
617        // Make it not wider than 1/2 of the screen
618        Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
619        lbl.setMaxWidth(screenSize.width/2);
620        // Disable default Enter key binding to allow dialog's one (then enables to hit default button from here)
621        lbl.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), new Object());
622        return lbl;
623    }
624
625    /**
626     * Configures how this dialog support for context sensitive help.
627     * <ul>
628     *  <li>if helpTopic is null, the dialog doesn't provide context sensitive help</li>
629     *  <li>if helpTopic != null, the dialog redirect user to the help page for this helpTopic when
630     *  the user clicks F1 in the dialog</li>
631     *  <li>if showHelpButton is true, the dialog displays "Help" button (rightmost button in
632     *  the button row)</li>
633     * </ul>
634     *
635     * @param helpTopic the help topic
636     * @param showHelpButton true, if the dialog displays a help button
637     * @return {@code this}
638     */
639    public ExtendedDialog configureContextsensitiveHelp(String helpTopic, boolean showHelpButton) {
640        this.helpTopic = helpTopic;
641        this.showHelpButton = showHelpButton;
642        return this;
643    }
644
645    class HelpAction extends AbstractAction {
646        public HelpAction() {
647            putValue(SHORT_DESCRIPTION, tr("Show help information"));
648            putValue(NAME, tr("Help"));
649            putValue(SMALL_ICON, ImageProvider.get("help"));
650            setEnabled(!Main.isOffline(OnlineResource.JOSM_WEBSITE));
651        }
652
653        @Override
654        public void actionPerformed(ActionEvent e) {
655            HelpBrowser.setUrlForHelpTopic(helpTopic);
656        }
657    }
658}