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