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