001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.util;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.BasicStroke;
007import java.awt.Color;
008import java.awt.Component;
009import java.awt.Container;
010import java.awt.Dialog;
011import java.awt.Dimension;
012import java.awt.DisplayMode;
013import java.awt.Font;
014import java.awt.Frame;
015import java.awt.GraphicsDevice;
016import java.awt.GraphicsEnvironment;
017import java.awt.GridBagLayout;
018import java.awt.HeadlessException;
019import java.awt.Image;
020import java.awt.Stroke;
021import java.awt.Toolkit;
022import java.awt.Window;
023import java.awt.datatransfer.Clipboard;
024import java.awt.event.ActionListener;
025import java.awt.event.HierarchyEvent;
026import java.awt.event.HierarchyListener;
027import java.awt.event.KeyEvent;
028import java.awt.event.MouseAdapter;
029import java.awt.event.MouseEvent;
030import java.awt.image.FilteredImageSource;
031import java.lang.reflect.InvocationTargetException;
032import java.util.Enumeration;
033import java.util.EventObject;
034import java.util.concurrent.Callable;
035import java.util.concurrent.ExecutionException;
036import java.util.concurrent.FutureTask;
037
038import javax.swing.GrayFilter;
039import javax.swing.Icon;
040import javax.swing.ImageIcon;
041import javax.swing.JComponent;
042import javax.swing.JLabel;
043import javax.swing.JOptionPane;
044import javax.swing.JPanel;
045import javax.swing.JPopupMenu;
046import javax.swing.JScrollPane;
047import javax.swing.Scrollable;
048import javax.swing.SwingUtilities;
049import javax.swing.Timer;
050import javax.swing.ToolTipManager;
051import javax.swing.UIManager;
052import javax.swing.plaf.FontUIResource;
053
054import org.openstreetmap.josm.Main;
055import org.openstreetmap.josm.gui.ExtendedDialog;
056import org.openstreetmap.josm.gui.widgets.HtmlPanel;
057import org.openstreetmap.josm.tools.CheckParameterUtil;
058import org.openstreetmap.josm.tools.ColorHelper;
059import org.openstreetmap.josm.tools.GBC;
060import org.openstreetmap.josm.tools.ImageOverlay;
061import org.openstreetmap.josm.tools.ImageProvider;
062import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
063import org.openstreetmap.josm.tools.LanguageInfo;
064import org.openstreetmap.josm.tools.bugreport.BugReport;
065import org.openstreetmap.josm.tools.bugreport.ReportedException;
066
067/**
068 * basic gui utils
069 */
070public final class GuiHelper {
071
072    private GuiHelper() {
073        // Hide default constructor for utils classes
074    }
075
076    /**
077     * disable / enable a component and all its child components
078     * @param root component
079     * @param enabled enabled state
080     */
081    public static void setEnabledRec(Container root, boolean enabled) {
082        root.setEnabled(enabled);
083        Component[] children = root.getComponents();
084        for (Component child : children) {
085            if (child instanceof Container) {
086                setEnabledRec((Container) child, enabled);
087            } else {
088                child.setEnabled(enabled);
089            }
090        }
091    }
092
093    public static void executeByMainWorkerInEDT(final Runnable task) {
094        Main.worker.submit(new Runnable() {
095            @Override
096            public void run() {
097                runInEDTAndWait(task);
098            }
099        });
100    }
101
102    /**
103     * Executes asynchronously a runnable in
104     * <a href="http://docs.oracle.com/javase/tutorial/uiswing/concurrency/dispatch.html">Event Dispatch Thread</a>.
105     * @param task The runnable to execute
106     * @see SwingUtilities#invokeLater
107     */
108    public static void runInEDT(Runnable task) {
109        if (SwingUtilities.isEventDispatchThread()) {
110            task.run();
111        } else {
112            SwingUtilities.invokeLater(task);
113        }
114    }
115
116    /**
117     * Executes synchronously a runnable in
118     * <a href="http://docs.oracle.com/javase/tutorial/uiswing/concurrency/dispatch.html">Event Dispatch Thread</a>.
119     * @param task The runnable to execute
120     * @see SwingUtilities#invokeAndWait
121     */
122    public static void runInEDTAndWait(Runnable task) {
123        if (SwingUtilities.isEventDispatchThread()) {
124            task.run();
125        } else {
126            try {
127                SwingUtilities.invokeAndWait(task);
128            } catch (InterruptedException | InvocationTargetException e) {
129                Main.error(e);
130            }
131        }
132    }
133
134    /**
135     * Executes synchronously a runnable in
136     * <a href="http://docs.oracle.com/javase/tutorial/uiswing/concurrency/dispatch.html">Event Dispatch Thread</a>.
137     * <p>
138     * Passes on the exception that was thrown to the thread calling this.
139     * The exception is wrapped using a {@link ReportedException}.
140     * @param task The runnable to execute
141     * @see SwingUtilities#invokeAndWait
142     * @since 10271
143     */
144    public static void runInEDTAndWaitWithException(Runnable task) {
145        if (SwingUtilities.isEventDispatchThread()) {
146            task.run();
147        } else {
148            try {
149                SwingUtilities.invokeAndWait(task);
150            } catch (InterruptedException | InvocationTargetException e) {
151                throw BugReport.intercept(e).put("task", task);
152            }
153        }
154    }
155
156    /**
157     * Executes synchronously a callable in
158     * <a href="http://docs.oracle.com/javase/tutorial/uiswing/concurrency/dispatch.html">Event Dispatch Thread</a>
159     * and return a value.
160     * @param <V> the result type of method <tt>call</tt>
161     * @param callable The callable to execute
162     * @return The computed result
163     * @since 7204
164     */
165    public static <V> V runInEDTAndWaitAndReturn(Callable<V> callable) {
166        if (SwingUtilities.isEventDispatchThread()) {
167            try {
168                return callable.call();
169            } catch (Exception e) {
170                Main.error(e);
171                return null;
172            }
173        } else {
174            FutureTask<V> task = new FutureTask<>(callable);
175            SwingUtilities.invokeLater(task);
176            try {
177                return task.get();
178            } catch (InterruptedException | ExecutionException e) {
179                Main.error(e);
180                return null;
181            }
182        }
183    }
184
185    /**
186     * This function fails if it was not called from the EDT thread.
187     * @throws IllegalStateException if called from wrong thread.
188     * @since 10271
189     */
190    public static void assertCallFromEdt() {
191        if (!SwingUtilities.isEventDispatchThread()) {
192            throw new IllegalStateException(
193                    "Needs to be called from the EDT thread, not from " + Thread.currentThread().getName());
194        }
195    }
196
197    /**
198     * Warns user about a dangerous action requiring confirmation.
199     * @param title Title of dialog
200     * @param content Content of dialog
201     * @param baseActionIcon Unused? FIXME why is this parameter unused?
202     * @param continueToolTip Tooltip to display for "continue" button
203     * @return true if the user wants to cancel, false if they want to continue
204     */
205    public static boolean warnUser(String title, String content, ImageIcon baseActionIcon, String continueToolTip) {
206        ExtendedDialog dlg = new ExtendedDialog(Main.parent,
207                title, new String[] {tr("Cancel"), tr("Continue")});
208        dlg.setContent(content);
209        dlg.setButtonIcons(new Icon[] {
210                    new ImageProvider("cancel").setMaxSize(ImageSizes.LARGEICON).get(),
211                    new ImageProvider("upload").setMaxSize(ImageSizes.LARGEICON).addOverlay(
212                            new ImageOverlay(new ImageProvider("warning-small"), 0.5, 0.5, 1.0, 1.0)).get()});
213        dlg.setToolTipTexts(new String[] {
214                tr("Cancel"),
215                continueToolTip});
216        dlg.setIcon(JOptionPane.WARNING_MESSAGE);
217        dlg.setCancelButton(1);
218        return dlg.showDialog().getValue() != 2;
219    }
220
221    /**
222     * Notifies user about an error received from an external source as an HTML page.
223     * @param parent Parent component
224     * @param title Title of dialog
225     * @param message Message displayed at the top of the dialog
226     * @param html HTML content to display (real error message)
227     * @since 7312
228     */
229    public static void notifyUserHtmlError(Component parent, String title, String message, String html) {
230        JPanel p = new JPanel(new GridBagLayout());
231        p.add(new JLabel(message), GBC.eol());
232        p.add(new JLabel(tr("Received error page:")), GBC.eol());
233        JScrollPane sp = embedInVerticalScrollPane(new HtmlPanel(html));
234        sp.setPreferredSize(new Dimension(640, 240));
235        p.add(sp, GBC.eol().fill(GBC.BOTH));
236
237        ExtendedDialog ed = new ExtendedDialog(parent, title, new String[] {tr("OK")});
238        ed.setButtonIcons(new String[] {"ok.png"});
239        ed.setContent(p);
240        ed.showDialog();
241    }
242
243    /**
244     * Replies the disabled (grayed) version of the specified image.
245     * @param image The image to disable
246     * @return The disabled (grayed) version of the specified image, brightened by 20%.
247     * @since 5484
248     */
249    public static Image getDisabledImage(Image image) {
250        return Toolkit.getDefaultToolkit().createImage(
251                new FilteredImageSource(image.getSource(), new GrayFilter(true, 20)));
252    }
253
254    /**
255     * Replies the disabled (grayed) version of the specified icon.
256     * @param icon The icon to disable
257     * @return The disabled (grayed) version of the specified icon, brightened by 20%.
258     * @since 5484
259     */
260    public static ImageIcon getDisabledIcon(ImageIcon icon) {
261        return new ImageIcon(getDisabledImage(icon.getImage()));
262    }
263
264    /**
265     * Attaches a {@code HierarchyListener} to the specified {@code Component} that
266     * will set its parent dialog resizeable. Use it before a call to JOptionPane#showXXXXDialog
267     * to make it resizeable.
268     * @param pane The component that will be displayed
269     * @param minDimension The minimum dimension that will be set for the dialog. Ignored if null
270     * @return {@code pane}
271     * @since 5493
272     */
273    public static Component prepareResizeableOptionPane(final Component pane, final Dimension minDimension) {
274        if (pane != null) {
275            pane.addHierarchyListener(new HierarchyListener() {
276                @Override
277                public void hierarchyChanged(HierarchyEvent e) {
278                    Window window = SwingUtilities.getWindowAncestor(pane);
279                    if (window instanceof Dialog) {
280                        Dialog dialog = (Dialog) window;
281                        if (!dialog.isResizable()) {
282                            dialog.setResizable(true);
283                            if (minDimension != null) {
284                                dialog.setMinimumSize(minDimension);
285                            }
286                        }
287                    }
288                }
289            });
290        }
291        return pane;
292    }
293
294    /**
295     * Schedules a new Timer to be run in the future (once or several times).
296     * @param initialDelay milliseconds for the initial and between-event delay if repeatable
297     * @param actionListener an initial listener; can be null
298     * @param repeats specify false to make the timer stop after sending its first action event
299     * @return The (started) timer.
300     * @since 5735
301     */
302    public static Timer scheduleTimer(int initialDelay, ActionListener actionListener, boolean repeats) {
303        Timer timer = new Timer(initialDelay, actionListener);
304        timer.setRepeats(repeats);
305        timer.start();
306        return timer;
307    }
308
309    /**
310     * Return s new BasicStroke object with given thickness and style
311     * @param code = 3.5 -&gt; thickness=3.5px; 3.5 10 5 -&gt; thickness=3.5px, dashed: 10px filled + 5px empty
312     * @return stroke for drawing
313     */
314    public static Stroke getCustomizedStroke(String code) {
315        String[] s = code.trim().split("[^\\.0-9]+");
316
317        if (s.length == 0) return new BasicStroke();
318        float w;
319        try {
320            w = Float.parseFloat(s[0]);
321        } catch (NumberFormatException ex) {
322            w = 1.0f;
323        }
324        if (s.length > 1) {
325            float[] dash = new float[s.length-1];
326            float sumAbs = 0;
327            try {
328                for (int i = 0; i < s.length-1; i++) {
329                   dash[i] = Float.parseFloat(s[i+1]);
330                   sumAbs += Math.abs(dash[i]);
331                }
332            } catch (NumberFormatException ex) {
333                Main.error("Error in stroke preference format: "+code);
334                dash = new float[]{5.0f};
335            }
336            if (sumAbs < 1e-1) {
337                Main.error("Error in stroke dash fomat (all zeros): "+code);
338                return new BasicStroke(w);
339            }
340            // dashed stroke
341            return new BasicStroke(w, BasicStroke.CAP_BUTT,
342                    BasicStroke.JOIN_MITER, 10.0f, dash, 0.0f);
343        } else {
344            if (w > 1) {
345                // thick stroke
346                return new BasicStroke(w, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND);
347            } else {
348                // thin stroke
349                return new BasicStroke(w);
350            }
351        }
352    }
353
354    /**
355     * Gets the font used to display monospaced text in a component, if possible.
356     * @param component The component
357     * @return the font used to display monospaced text in a component, if possible
358     * @since 7896
359     */
360    public static Font getMonospacedFont(JComponent component) {
361        // Special font for Khmer script
362        if ("km".equals(LanguageInfo.getJOSMLocaleCode())) {
363            return component.getFont();
364        } else {
365            return new Font("Monospaced", component.getFont().getStyle(), component.getFont().getSize());
366        }
367    }
368
369    /**
370     * Gets the font used to display JOSM title in about dialog and splash screen.
371     * @return title font
372     * @since 5797
373     */
374    public static Font getTitleFont() {
375        return new Font("SansSerif", Font.BOLD, 23);
376    }
377
378    /**
379     * Embeds the given component into a new vertical-only scrollable {@code JScrollPane}.
380     * @param panel The component to embed
381     * @return the vertical scrollable {@code JScrollPane}
382     * @since 6666
383     */
384    public static JScrollPane embedInVerticalScrollPane(Component panel) {
385        return new JScrollPane(panel, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
386    }
387
388    /**
389     * Set the default unit increment for a {@code JScrollPane}.
390     *
391     * This fixes slow mouse wheel scrolling when the content of the {@code JScrollPane}
392     * is a {@code JPanel} or other component that does not implement the {@link Scrollable}
393     * interface.
394     * The default unit increment is 1 pixel. Multiplied by the number of unit increments
395     * per mouse wheel "click" (platform dependent, usually 3), this makes a very
396     * sluggish mouse wheel experience.
397     * This methods sets the unit increment to a larger, more reasonable value.
398     * @param sp the scroll pane
399     * @return the scroll pane (same object) with fixed unit increment
400     * @throws IllegalArgumentException if the component inside of the scroll pane
401     * implements the {@code Scrollable} interface ({@code JTree}, {@code JLayer},
402     * {@code JList}, {@code JTextComponent} and {@code JTable})
403     */
404    public static JScrollPane setDefaultIncrement(JScrollPane sp) {
405        if (sp.getViewport().getView() instanceof Scrollable) {
406            throw new IllegalArgumentException();
407        }
408        sp.getVerticalScrollBar().setUnitIncrement(10);
409        sp.getHorizontalScrollBar().setUnitIncrement(10);
410        return sp;
411    }
412
413    /**
414     * Returns extended modifier key used as the appropriate accelerator key for menu shortcuts.
415     * It is advised everywhere to use {@link Toolkit#getMenuShortcutKeyMask()} to get the cross-platform modifier, but:
416     * <ul>
417     * <li>it returns KeyEvent.CTRL_MASK instead of KeyEvent.CTRL_DOWN_MASK. We used the extended
418     *    modifier for years, and Oracle recommends to use it instead, so it's best to keep it</li>
419     * <li>the method throws a HeadlessException ! So we would need to handle it for unit tests anyway</li>
420     * </ul>
421     * @return extended modifier key used as the appropriate accelerator key for menu shortcuts
422     * @since 7539
423     */
424    public static int getMenuShortcutKeyMaskEx() {
425        return Main.isPlatformOsx() ? KeyEvent.META_DOWN_MASK : KeyEvent.CTRL_DOWN_MASK;
426    }
427
428    /**
429     * Sets a global font for all UI, replacing default font of current look and feel.
430     * @param name Font name. It is up to the caller to make sure the font exists
431     * @throws IllegalArgumentException if name is null
432     * @since 7896
433     */
434    public static void setUIFont(String name) {
435        CheckParameterUtil.ensureParameterNotNull(name, "name");
436        Main.info("Setting "+name+" as the default UI font");
437        Enumeration<?> keys = UIManager.getDefaults().keys();
438        while (keys.hasMoreElements()) {
439            Object key = keys.nextElement();
440            Object value = UIManager.get(key);
441            if (value instanceof FontUIResource) {
442                FontUIResource fui = (FontUIResource) value;
443                UIManager.put(key, new FontUIResource(name, fui.getStyle(), fui.getSize()));
444            }
445        }
446    }
447
448    /**
449     * Sets the background color for this component, and adjust the foreground color so the text remains readable.
450     * @param c component
451     * @param background background color
452     * @since 9223
453     */
454    public static void setBackgroundReadable(JComponent c, Color background) {
455        c.setBackground(background);
456        c.setForeground(ColorHelper.getForegroundColor(background));
457    }
458
459    /**
460     * Gets the size of the screen. On systems with multiple displays, the primary display is used.
461     * This method returns always 800x600 in headless mode (useful for unit tests).
462     * @return the size of this toolkit's screen, in pixels, or 800x600
463     * @see Toolkit#getScreenSize
464     * @since 9576
465     */
466    public static Dimension getScreenSize() {
467        return GraphicsEnvironment.isHeadless() ? new Dimension(800, 600) : Toolkit.getDefaultToolkit().getScreenSize();
468    }
469
470    /**
471     * Gets the size of the screen. On systems with multiple displays,
472     * contrary to {@link #getScreenSize()}, the biggest display is used.
473     * This method returns always 800x600 in headless mode (useful for unit tests).
474     * @return the size of maximum screen, in pixels, or 800x600
475     * @see Toolkit#getScreenSize
476     * @since 10470
477     */
478    public static Dimension getMaximumScreenSize() {
479        if (GraphicsEnvironment.isHeadless()) {
480            return new Dimension(800, 600);
481        }
482
483        int height = 0;
484        int width = 0;
485        for (GraphicsDevice gd: GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices()) {
486            DisplayMode dm = gd.getDisplayMode();
487            if (dm != null) {
488                height = Math.max(height, dm.getHeight());
489                width = Math.max(width, dm.getWidth());
490            }
491        }
492        if (height == 0 || width == 0) {
493            return new Dimension(800, 600);
494        }
495        return new Dimension(width, height);
496    }
497
498    /**
499     * Gets the singleton instance of the system selection as a <code>Clipboard</code> object.
500     * This allows an application to read and modify the current, system-wide selection.
501     * @return the system selection as a <code>Clipboard</code>, or <code>null</code> if the native platform does not
502     *         support a system selection <code>Clipboard</code> or if GraphicsEnvironment.isHeadless() returns true
503     * @see Toolkit#getSystemSelection
504     * @since 9576
505     */
506    public static Clipboard getSystemSelection() {
507        return GraphicsEnvironment.isHeadless() ? null : Toolkit.getDefaultToolkit().getSystemSelection();
508    }
509
510    /**
511     * Returns the first <code>Window</code> ancestor of event source, or
512     * {@code null} if event source is not a component contained inside a <code>Window</code>.
513     * @param e event object
514     * @return a Window, or {@code null}
515     * @since 9916
516     */
517    public static Window getWindowAncestorFor(EventObject e) {
518        if (e != null) {
519            Object source = e.getSource();
520            if (source instanceof Component) {
521                Window ancestor = SwingUtilities.getWindowAncestor((Component) source);
522                if (ancestor != null) {
523                    return ancestor;
524                } else {
525                    Container parent = ((Component) source).getParent();
526                    if (parent instanceof JPopupMenu) {
527                        Component invoker = ((JPopupMenu) parent).getInvoker();
528                        return SwingUtilities.getWindowAncestor(invoker);
529                    }
530                }
531            }
532        }
533        return null;
534    }
535
536    /**
537     * Extends tooltip dismiss delay to a default value of 1 minute for the given component.
538     * @param c component
539     * @since 10024
540     */
541    public static void extendTooltipDelay(Component c) {
542        extendTooltipDelay(c, 60000);
543    }
544
545    /**
546     * Extends tooltip dismiss delay to the specified value for the given component.
547     * @param c component
548     * @param delay tooltip dismiss delay in milliseconds
549     * @see <a href="http://stackoverflow.com/a/6517902/2257172">http://stackoverflow.com/a/6517902/2257172</a>
550     * @since 10024
551     */
552    public static void extendTooltipDelay(Component c, final int delay) {
553        final int defaultDismissTimeout = ToolTipManager.sharedInstance().getDismissDelay();
554        c.addMouseListener(new MouseAdapter() {
555            @Override
556            public void mouseEntered(MouseEvent me) {
557                ToolTipManager.sharedInstance().setDismissDelay(delay);
558            }
559
560            @Override
561            public void mouseExited(MouseEvent me) {
562                ToolTipManager.sharedInstance().setDismissDelay(defaultDismissTimeout);
563            }
564        });
565    }
566
567    /**
568     * Returns the specified component's <code>Frame</code> without throwing exception in headless mode.
569     *
570     * @param parentComponent the <code>Component</code> to check for a <code>Frame</code>
571     * @return the <code>Frame</code> that contains the component, or <code>getRootFrame</code>
572     *         if the component is <code>null</code>, or does not have a valid <code>Frame</code> parent
573     * @see JOptionPane#getFrameForComponent
574     * @see GraphicsEnvironment#isHeadless
575     * @since 10035
576     */
577    public static Frame getFrameForComponent(Component parentComponent) {
578        try {
579            return JOptionPane.getFrameForComponent(parentComponent);
580        } catch (HeadlessException e) {
581            Main.debug(e);
582            return null;
583        }
584    }
585}