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