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