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