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