001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.event.KeyEvent;
007import java.util.ArrayList;
008import java.util.Arrays;
009import java.util.Comparator;
010import java.util.HashMap;
011import java.util.List;
012import java.util.Map;
013import java.util.Optional;
014import java.util.concurrent.CopyOnWriteArrayList;
015import java.util.function.Predicate;
016import java.util.stream.Collectors;
017
018import javax.swing.AbstractAction;
019import javax.swing.AbstractButton;
020import javax.swing.Action;
021import javax.swing.JMenu;
022import javax.swing.KeyStroke;
023import javax.swing.UIManager;
024import javax.swing.text.JTextComponent;
025
026import org.openstreetmap.josm.data.Preferences;
027import org.openstreetmap.josm.spi.preferences.Config;
028
029/**
030 * Global shortcut class.
031 *
032 * Note: This class represents a single shortcut, contains the factory to obtain
033 *       shortcut objects from, manages shortcuts and shortcut collisions, and
034 *       finally manages loading and saving shortcuts to/from the preferences.
035 *
036 * Action authors: You only need the {@link #registerShortcut} factory. Ignore everything else.
037 *
038 * All: Use only public methods that are also marked to be used. The others are
039 *      public so the shortcut preferences can use them.
040 * @since 1084
041 */
042public final class Shortcut {
043    /** the unique ID of the shortcut */
044    private final String shortText;
045    /** a human readable description that will be shown in the preferences */
046    private String longText;
047    /** the key, the caller requested */
048    private final int requestedKey;
049    /** the group, the caller requested */
050    private final int requestedGroup;
051    /** the key that actually is used */
052    private int assignedKey;
053    /** the modifiers that are used */
054    private int assignedModifier;
055    /** true if it got assigned what was requested.
056     * (Note: modifiers will be ignored in favour of group when loading it from the preferences then.) */
057    private boolean assignedDefault;
058    /** true if the user changed this shortcut */
059    private boolean assignedUser;
060    /** true if the user cannot change this shortcut (Note: it also will not be saved into the preferences) */
061    private boolean automatic;
062    /** true if the user requested this shortcut to be set to its default value
063     * (will happen on next restart, as this shortcut will not be saved to the preferences) */
064    private boolean reset;
065
066    // simple constructor
067    private Shortcut(String shortText, String longText, int requestedKey, int requestedGroup, int assignedKey, int assignedModifier,
068            boolean assignedDefault, boolean assignedUser) {
069        this.shortText = shortText;
070        this.longText = longText;
071        this.requestedKey = requestedKey;
072        this.requestedGroup = requestedGroup;
073        this.assignedKey = assignedKey;
074        this.assignedModifier = assignedModifier;
075        this.assignedDefault = assignedDefault;
076        this.assignedUser = assignedUser;
077        this.automatic = false;
078        this.reset = false;
079    }
080
081    public String getShortText() {
082        return shortText;
083    }
084
085    public String getLongText() {
086        return longText;
087    }
088
089    // a shortcut will be renamed when it is handed out again, because the original name may be a dummy
090    private void setLongText(String longText) {
091        this.longText = longText;
092    }
093
094    public int getAssignedKey() {
095        return assignedKey;
096    }
097
098    public int getAssignedModifier() {
099        return assignedModifier;
100    }
101
102    public boolean isAssignedDefault() {
103        return assignedDefault;
104    }
105
106    public boolean isAssignedUser() {
107        return assignedUser;
108    }
109
110    public boolean isAutomatic() {
111        return automatic;
112    }
113
114    public boolean isChangeable() {
115        return !automatic && !"core:none".equals(shortText);
116    }
117
118    private boolean isReset() {
119        return reset;
120    }
121
122    /**
123     * FOR PREF PANE ONLY
124     */
125    public void setAutomatic() {
126        automatic = true;
127    }
128
129    /**
130     * FOR PREF PANE ONLY.<p>
131     * Sets the modifiers that are used.
132     * @param assignedModifier assigned modifier
133     */
134    public void setAssignedModifier(int assignedModifier) {
135        this.assignedModifier = assignedModifier;
136    }
137
138    /**
139     * FOR PREF PANE ONLY.<p>
140     * Sets the key that actually is used.
141     * @param assignedKey assigned key
142     */
143    public void setAssignedKey(int assignedKey) {
144        this.assignedKey = assignedKey;
145    }
146
147    /**
148     * FOR PREF PANE ONLY.<p>
149     * Sets whether the user has changed this shortcut.
150     * @param assignedUser {@code true} if the user has changed this shortcut
151     */
152    public void setAssignedUser(boolean assignedUser) {
153        this.reset = (this.assignedUser || reset) && !assignedUser;
154        if (assignedUser) {
155            assignedDefault = false;
156        } else if (reset) {
157            assignedKey = requestedKey;
158            assignedModifier = findModifier(requestedGroup, null);
159        }
160        this.assignedUser = assignedUser;
161    }
162
163    /**
164     * Use this to register the shortcut with Swing
165     * @return the key stroke
166     */
167    public KeyStroke getKeyStroke() {
168        if (assignedModifier != -1)
169            return KeyStroke.getKeyStroke(assignedKey, assignedModifier);
170        return null;
171    }
172
173    // create a shortcut object from an string as saved in the preferences
174    private Shortcut(String prefString) {
175        List<String> s = new ArrayList<>(Config.getPref().getList(prefString));
176        this.shortText = prefString.substring(15);
177        this.longText = s.get(0);
178        this.requestedKey = Integer.parseInt(s.get(1));
179        this.requestedGroup = Integer.parseInt(s.get(2));
180        this.assignedKey = Integer.parseInt(s.get(3));
181        this.assignedModifier = Integer.parseInt(s.get(4));
182        this.assignedDefault = Boolean.parseBoolean(s.get(5));
183        this.assignedUser = Boolean.parseBoolean(s.get(6));
184    }
185
186    private void saveDefault() {
187        Config.getPref().getList("shortcut.entry."+shortText, Arrays.asList(longText,
188            String.valueOf(requestedKey), String.valueOf(requestedGroup), String.valueOf(requestedKey),
189            String.valueOf(getGroupModifier(requestedGroup)), String.valueOf(true), String.valueOf(false)));
190    }
191
192    // get a string that can be put into the preferences
193    private boolean save() {
194        if (isAutomatic() || isReset() || !isAssignedUser()) {
195            return Config.getPref().putList("shortcut.entry."+shortText, null);
196        } else {
197            return Config.getPref().putList("shortcut.entry."+shortText, Arrays.asList(longText,
198                String.valueOf(requestedKey), String.valueOf(requestedGroup), String.valueOf(assignedKey),
199                String.valueOf(assignedModifier), String.valueOf(assignedDefault), String.valueOf(assignedUser)));
200        }
201    }
202
203    private boolean isSame(int isKey, int isModifier) {
204        // an unassigned shortcut is different from any other shortcut
205        return isKey == assignedKey && isModifier == assignedModifier && assignedModifier != getGroupModifier(NONE);
206    }
207
208    public boolean isEvent(KeyEvent e) {
209        KeyStroke ks = getKeyStroke();
210        return ks != null && ks.equals(KeyStroke.getKeyStroke(e.getKeyCode(), e.getModifiersEx()));
211    }
212
213    /**
214     * use this to set a menu's mnemonic
215     * @param menu menu
216     */
217    public void setMnemonic(JMenu menu) {
218        if (assignedModifier == getGroupModifier(MNEMONIC) && getKeyStroke() != null && KeyEvent.getKeyText(assignedKey).length() == 1) {
219            menu.setMnemonic(KeyEvent.getKeyText(assignedKey).charAt(0)); //getKeyStroke().getKeyChar() seems not to work here
220        }
221    }
222
223    /**
224     * use this to set a buttons's mnemonic
225     * @param button button
226     */
227    public void setMnemonic(AbstractButton button) {
228        if (assignedModifier == getGroupModifier(MNEMONIC) && getKeyStroke() != null && KeyEvent.getKeyText(assignedKey).length() == 1) {
229            button.setMnemonic(KeyEvent.getKeyText(assignedKey).charAt(0)); //getKeyStroke().getKeyChar() seems not to work here
230        }
231    }
232
233    /**
234     * Sets the mnemonic key on a text component.
235     * @param component component
236     */
237    public void setFocusAccelerator(JTextComponent component) {
238        if (assignedModifier == getGroupModifier(MNEMONIC) && getKeyStroke() != null && KeyEvent.getKeyText(assignedKey).length() == 1) {
239            component.setFocusAccelerator(KeyEvent.getKeyText(assignedKey).charAt(0));
240        }
241    }
242
243    /**
244     * use this to set a actions's accelerator
245     * @param action action
246     */
247    public void setAccelerator(AbstractAction action) {
248        if (getKeyStroke() != null) {
249            action.putValue(AbstractAction.ACCELERATOR_KEY, getKeyStroke());
250        }
251    }
252
253    /**
254     * Returns a human readable text for the shortcut.
255     * @return a human readable text for the shortcut
256     */
257    public String getKeyText() {
258        return getKeyText(getKeyStroke());
259    }
260
261    /**
262     * Returns a human readable text for the key stroke.
263     * @param keyStroke key stroke to convert to human readable text
264     * @return a human readable text for the key stroke
265     * @since 12520
266     */
267    public static String getKeyText(KeyStroke keyStroke) {
268        if (keyStroke == null) return "";
269        String modifText = KeyEvent.getModifiersExText(keyStroke.getModifiers());
270        if ("".equals(modifText)) return KeyEvent.getKeyText(keyStroke.getKeyCode());
271        return modifText + '+' + KeyEvent.getKeyText(keyStroke.getKeyCode());
272    }
273
274    /**
275     * Sets the action tooltip to the tooltip text plus the {@linkplain #getKeyText(KeyStroke) key stroke text}
276     * this shortcut represents.
277     *
278     * @param action action
279     * @param tooltip Tooltip text to display
280     * @since 14689
281     */
282    public void setTooltip(Action action, String tooltip) {
283        setTooltip(action, tooltip, getKeyStroke());
284    }
285
286    /**
287     * Sets the action tooltip to the tooltip text plus the {@linkplain #getKeyText(KeyStroke) key stroke text}.
288     *
289     * @param action action
290     * @param tooltip Tooltip text to display
291     * @param keyStroke Key stroke associated (to display accelerator between parenthesis)
292     * @since 14689
293     */
294    public static void setTooltip(Action action, String tooltip, KeyStroke keyStroke) {
295        action.putValue(Action.SHORT_DESCRIPTION, makeTooltip(tooltip, keyStroke));
296    }
297
298    @Override
299    public String toString() {
300        return getKeyText();
301    }
302
303    ///////////////////////////////
304    // everything's static below //
305    ///////////////////////////////
306
307    // here we store our shortcuts
308    private static ShortcutCollection shortcuts = new ShortcutCollection();
309
310    private static class ShortcutCollection extends CopyOnWriteArrayList<Shortcut> {
311        private static final long serialVersionUID = 1L;
312        @Override
313        public boolean add(Shortcut shortcut) {
314            // expensive consistency check only in debug mode
315            if (Logging.isDebugEnabled()
316                    && stream().map(Shortcut::getShortText).anyMatch(shortcut.getShortText()::equals)) {
317                Logging.warn(new AssertionError(shortcut.getShortText() + " already added"));
318            }
319            return super.add(shortcut);
320        }
321
322        void replace(Shortcut newShortcut) {
323            final Optional<Shortcut> existing = findShortcutByKeyOrShortText(-1, NONE, newShortcut.shortText);
324            if (existing.isPresent()) {
325                replaceAll(sc -> existing.get() == sc ? newShortcut : sc);
326            } else {
327                add(newShortcut);
328            }
329        }
330    }
331
332    // and here our modifier groups
333    private static Map<Integer, Integer> groups = new HashMap<>();
334
335    // check if something collides with an existing shortcut
336
337    /**
338     * Returns the registered shortcut fot the key and modifier
339     * @param requestedKey the requested key
340     * @param modifier the modifier
341     * @return an {@link Optional} registered shortcut, never {@code null}
342     */
343    public static Optional<Shortcut> findShortcut(int requestedKey, int modifier) {
344        return findShortcutByKeyOrShortText(requestedKey, modifier, null);
345    }
346
347    private static Optional<Shortcut> findShortcutByKeyOrShortText(int requestedKey, int modifier, String shortText) {
348        final Predicate<Shortcut> sameKey = sc -> modifier != getGroupModifier(NONE) && sc.isSame(requestedKey, modifier);
349        final Predicate<Shortcut> sameShortText = sc -> sc.getShortText().equals(shortText);
350        return shortcuts.stream()
351                .filter(sameKey.or(sameShortText))
352                .sorted(Comparator.comparingInt(sc -> sameShortText.test(sc) ? 0 : 1))
353                .findAny();
354    }
355
356    /**
357     * Returns a list of all shortcuts.
358     * @return a list of all shortcuts
359     */
360    public static List<Shortcut> listAll() {
361        return shortcuts.stream()
362                .filter(c -> !"core:none".equals(c.shortText))
363                .collect(Collectors.toList());
364    }
365
366    /** None group: used with KeyEvent.CHAR_UNDEFINED if no shortcut is defined */
367    public static final int NONE = 5000;
368    public static final int MNEMONIC = 5001;
369    /** Reserved group: for system shortcuts only */
370    public static final int RESERVED = 5002;
371    /** Direct group: no modifier */
372    public static final int DIRECT = 5003;
373    /** Alt group */
374    public static final int ALT = 5004;
375    /** Shift group */
376    public static final int SHIFT = 5005;
377    /** Command group. Matches CTRL modifier on Windows/Linux but META modifier on OS X */
378    public static final int CTRL = 5006;
379    /** Alt-Shift group */
380    public static final int ALT_SHIFT = 5007;
381    /** Alt-Command group. Matches ALT-CTRL modifier on Windows/Linux but ALT-META modifier on OS X */
382    public static final int ALT_CTRL = 5008;
383    /** Command-Shift group. Matches CTRL-SHIFT modifier on Windows/Linux but META-SHIFT modifier on OS X */
384    public static final int CTRL_SHIFT = 5009;
385    /** Alt-Command-Shift group. Matches ALT-CTRL-SHIFT modifier on Windows/Linux but ALT-META-SHIFT modifier on OS X */
386    public static final int ALT_CTRL_SHIFT = 5010;
387
388    /* for reassignment */
389    private static int[] mods = {ALT_CTRL, ALT_SHIFT, CTRL_SHIFT, ALT_CTRL_SHIFT};
390    private static int[] keys = {KeyEvent.VK_F1, KeyEvent.VK_F2, KeyEvent.VK_F3, KeyEvent.VK_F4,
391                                 KeyEvent.VK_F5, KeyEvent.VK_F6, KeyEvent.VK_F7, KeyEvent.VK_F8,
392                                 KeyEvent.VK_F9, KeyEvent.VK_F10, KeyEvent.VK_F11, KeyEvent.VK_F12};
393
394    // bootstrap
395    private static boolean initdone;
396    private static void doInit() {
397        if (initdone) return;
398        initdone = true;
399        int commandDownMask = PlatformManager.getPlatform().getMenuShortcutKeyMaskEx();
400        groups.put(NONE, -1);
401        groups.put(MNEMONIC, KeyEvent.ALT_DOWN_MASK);
402        groups.put(DIRECT, 0);
403        groups.put(ALT, KeyEvent.ALT_DOWN_MASK);
404        groups.put(SHIFT, KeyEvent.SHIFT_DOWN_MASK);
405        groups.put(CTRL, commandDownMask);
406        groups.put(ALT_SHIFT, KeyEvent.ALT_DOWN_MASK | KeyEvent.SHIFT_DOWN_MASK);
407        groups.put(ALT_CTRL, KeyEvent.ALT_DOWN_MASK | commandDownMask);
408        groups.put(CTRL_SHIFT, commandDownMask | KeyEvent.SHIFT_DOWN_MASK);
409        groups.put(ALT_CTRL_SHIFT, KeyEvent.ALT_DOWN_MASK | commandDownMask | KeyEvent.SHIFT_DOWN_MASK);
410
411        // (1) System reserved shortcuts
412        PlatformManager.getPlatform().initSystemShortcuts();
413        // (2) User defined shortcuts
414        Preferences.main().getAllPrefixCollectionKeys("shortcut.entry.").stream()
415                .map(Shortcut::new)
416                .filter(sc -> !findShortcut(sc.getAssignedKey(), sc.getAssignedModifier()).isPresent())
417                .sorted(Comparator.comparing(sc -> sc.isAssignedUser() ? 1 : sc.isAssignedDefault() ? 2 : 3))
418                .forEachOrdered(shortcuts::replace);
419    }
420
421    private static int getGroupModifier(int group) {
422        return Optional.ofNullable(groups.get(group)).orElse(-1);
423    }
424
425    private static int findModifier(int group, Integer modifier) {
426        if (modifier == null) {
427            modifier = getGroupModifier(group);
428            if (modifier == null) { // garbage in, no shortcut out
429                modifier = getGroupModifier(NONE);
430            }
431        }
432        return modifier;
433    }
434
435    // shutdown handling
436    public static boolean savePrefs() {
437        return shortcuts.stream()
438                .map(Shortcut::save)
439                .reduce(Boolean.FALSE, Boolean::logicalOr); // has changed
440    }
441
442    /**
443     * FOR PLATFORMHOOK USE ONLY.
444     * <p>
445     * This registers a system shortcut. See PlatformHook for details.
446     * @param shortText an ID. re-use a {@code "system:*"} ID if possible, else use something unique.
447     * @param longText this will be displayed in the shortcut preferences dialog. Better
448     * use something the user will recognize...
449     * @param key the key. Use a {@link KeyEvent KeyEvent.VK_*} constant here.
450     * @param modifier the modifier. Use a {@link KeyEvent KeyEvent.*_MASK} constant here.
451     * @return the system shortcut
452     */
453    public static Shortcut registerSystemShortcut(String shortText, String longText, int key, int modifier) {
454        final Optional<Shortcut> existing = findShortcutByKeyOrShortText(key, modifier, shortText);
455        if (existing.isPresent() && shortText.equals(existing.get().getShortText())) {
456            return existing.get();
457        } else if (existing.isPresent()) {
458            // this always is a logic error in the hook
459            Logging.error("CONFLICT WITH SYSTEM KEY " + shortText + ": " + existing.get());
460            return null;
461        }
462        final Shortcut shortcut = new Shortcut(shortText, longText, key, RESERVED, key, modifier, true, false);
463        shortcuts.add(shortcut);
464        return shortcut;
465    }
466
467    /**
468     * Register a shortcut linked to several characters.
469     *
470     * @param shortText an ID. re-use a {@code "system:*"} ID if possible, else use something unique.
471     * {@code "menu:*"} is reserved for menu mnemonics, {@code "core:*"} is reserved for
472     * actions that are part of JOSM's core. Use something like
473     * {@code <pluginname>+":"+<actionname>}.
474     * @param longText this will be displayed in the shortcut preferences dialog. Better
475     * use something the user will recognize...
476     * @param characters the characters you'd prefer
477     * @param requestedGroup the group this shortcut fits best. This will determine the
478     * modifiers your shortcut will get assigned. Use the constants defined above.
479     * @return the shortcut
480     */
481    public static List<Shortcut> registerMultiShortcuts(String shortText, String longText, List<Character> characters, int requestedGroup) {
482        List<Shortcut> result = new ArrayList<>();
483        int i = 1;
484        Map<Integer, Integer> regularKeyCodes = KeyboardUtils.getRegularKeyCodesMap();
485        for (Character c : characters) {
486            Integer code = (int) c;
487            result.add(registerShortcut(
488                    new StringBuilder(shortText).append(" (").append(i).append(')').toString(), longText,
489                    // Add extended keyCode if not a regular one
490                    regularKeyCodes.containsKey(code) ? regularKeyCodes.get(code) :
491                        isDeadKey(code) ? code : c | KeyboardUtils.EXTENDED_KEYCODE_FLAG,
492                    requestedGroup));
493            i++;
494        }
495        return result;
496    }
497
498    static boolean isDeadKey(int keyCode) {
499        return KeyEvent.VK_DEAD_GRAVE <= keyCode && keyCode <= KeyEvent.VK_DEAD_SEMIVOICED_SOUND;
500    }
501
502    /**
503     * Register a shortcut.
504     *
505     * Here you get your shortcuts from. The parameters are:
506     *
507     * @param shortText an ID. re-use a {@code "system:*"} ID if possible, else use something unique.
508     * {@code "menu:*"} is reserved for menu mnemonics, {@code "core:*"} is reserved for
509     * actions that are part of JOSM's core. Use something like
510     * {@code <pluginname>+":"+<actionname>}.
511     * @param longText this will be displayed in the shortcut preferences dialog. Better
512     * use something the user will recognize...
513     * @param requestedKey the key you'd prefer. Use a {@link KeyEvent KeyEvent.VK_*} constant here.
514     * @param requestedGroup the group this shortcut fits best. This will determine the
515     * modifiers your shortcut will get assigned. Use the constants defined above.
516     * @return the shortcut
517     */
518    public static Shortcut registerShortcut(String shortText, String longText, int requestedKey, int requestedGroup) {
519        return registerShortcut(shortText, longText, requestedKey, requestedGroup, null);
520    }
521
522    // and now the workhorse. same parameters as above, just one more
523    private static Shortcut registerShortcut(String shortText, String longText, int requestedKey, int requestedGroup, Integer modifier) {
524        doInit();
525        Integer defaultModifier = findModifier(requestedGroup, modifier);
526        final Optional<Shortcut> existing = findShortcutByKeyOrShortText(requestedKey, defaultModifier, shortText);
527        if (existing.isPresent() && shortText.equals(existing.get().getShortText())) {
528            // a re-register? maybe a sc already read from the preferences?
529            final Shortcut sc = existing.get();
530            sc.setLongText(longText); // or set by the platformHook, in this case the original longText doesn't match the real action
531            sc.saveDefault();
532            return sc;
533        } else if (existing.isPresent()) {
534            final Shortcut conflict = existing.get();
535            if (PlatformManager.isPlatformOsx()) {
536                // Try to reassign Meta to Ctrl
537                int newmodifier = findNewOsxModifier(requestedGroup);
538                if (!findShortcut(requestedKey, newmodifier).isPresent()) {
539                    Logging.info("Reassigning OSX shortcut '" + shortText + "' from Meta to Ctrl because of conflict with " + conflict);
540                    return reassignShortcut(shortText, longText, requestedKey, conflict, requestedGroup, requestedKey, newmodifier);
541                }
542            }
543            for (int m : mods) {
544                for (int k : keys) {
545                    int newmodifier = getGroupModifier(m);
546                    if (!findShortcut(k, newmodifier).isPresent()) {
547                        Logging.info("Reassigning shortcut '" + shortText + "' from " + modifier + " to " + newmodifier +
548                                " because of conflict with " + conflict);
549                        return reassignShortcut(shortText, longText, requestedKey, conflict, m, k, newmodifier);
550                    }
551                }
552            }
553        } else {
554            Shortcut newsc = new Shortcut(shortText, longText, requestedKey, requestedGroup, requestedKey, defaultModifier, true, false);
555            newsc.saveDefault();
556            shortcuts.add(newsc);
557            return newsc;
558        }
559
560        return null;
561    }
562
563    private static int findNewOsxModifier(int requestedGroup) {
564        switch (requestedGroup) {
565            case CTRL: return KeyEvent.CTRL_DOWN_MASK;
566            case ALT_CTRL: return KeyEvent.ALT_DOWN_MASK | KeyEvent.CTRL_DOWN_MASK;
567            case CTRL_SHIFT: return KeyEvent.CTRL_DOWN_MASK | KeyEvent.SHIFT_DOWN_MASK;
568            case ALT_CTRL_SHIFT: return KeyEvent.ALT_DOWN_MASK | KeyEvent.CTRL_DOWN_MASK | KeyEvent.SHIFT_DOWN_MASK;
569            default: return 0;
570        }
571    }
572
573    private static Shortcut reassignShortcut(String shortText, String longText, int requestedKey, Shortcut conflict,
574            int m, int k, int newmodifier) {
575        Shortcut newsc = new Shortcut(shortText, longText, requestedKey, m, k, newmodifier, false, false);
576        Logging.info(tr("Silent shortcut conflict: ''{0}'' moved by ''{1}'' to ''{2}''.",
577            shortText, conflict.getShortText(), newsc.getKeyText()));
578        newsc.saveDefault();
579        shortcuts.add(newsc);
580        return newsc;
581    }
582
583    /**
584     * Replies the platform specific key stroke for the 'Copy' command, i.e.
585     * 'Ctrl-C' on windows or 'Meta-C' on a Mac. null, if the platform specific
586     * copy command isn't known.
587     *
588     * @return the platform specific key stroke for the  'Copy' command
589     */
590    public static KeyStroke getCopyKeyStroke() {
591        return getKeyStrokeForShortKey("system:copy");
592    }
593
594    /**
595     * Replies the platform specific key stroke for the 'Paste' command, i.e.
596     * 'Ctrl-V' on windows or 'Meta-V' on a Mac. null, if the platform specific
597     * paste command isn't known.
598     *
599     * @return the platform specific key stroke for the 'Paste' command
600     */
601    public static KeyStroke getPasteKeyStroke() {
602        return getKeyStrokeForShortKey("system:paste");
603    }
604
605    /**
606     * Replies the platform specific key stroke for the 'Cut' command, i.e.
607     * 'Ctrl-X' on windows or 'Meta-X' on a Mac. null, if the platform specific
608     * 'Cut' command isn't known.
609     *
610     * @return the platform specific key stroke for the 'Cut' command
611     */
612    public static KeyStroke getCutKeyStroke() {
613        return getKeyStrokeForShortKey("system:cut");
614    }
615
616    private static KeyStroke getKeyStrokeForShortKey(String shortKey) {
617        return shortcuts.stream()
618                .filter(sc -> shortKey.equals(sc.getShortText()))
619                .findAny()
620                .map(Shortcut::getKeyStroke)
621                .orElse(null);
622    }
623
624    /**
625     * Returns the tooltip text plus the {@linkplain #getKeyText(KeyStroke) key stroke text}.
626     *
627     * Tooltips are usually not system dependent, unless the
628     * JVM is too dumb to provide correct names for all the keys.
629     *
630     * Some LAFs don't understand HTML, such as the OSX LAFs.
631     *
632     * @param tooltip Tooltip text to display
633     * @param keyStroke Key stroke associated (to display accelerator between parenthesis)
634     * @return Full tooltip text (tooltip + accelerator)
635     * @since 14689
636     */
637    public static String makeTooltip(String tooltip, KeyStroke keyStroke) {
638        final Optional<String> keyStrokeText = Optional.ofNullable(keyStroke)
639                .map(Shortcut::getKeyText)
640                .filter(text -> !text.isEmpty());
641
642        final String laf = UIManager.getLookAndFeel().getID();
643        // "Mac" is the native LAF, "Aqua" is Quaqua. Both use native menus with native tooltips.
644        final boolean canHtml = !(PlatformManager.isPlatformOsx() && (laf.contains("Mac") || laf.contains("Aqua")));
645
646        StringBuilder result = new StringBuilder(48);
647        if (canHtml) {
648            result.append("<html>");
649        }
650        result.append(tooltip);
651        if (keyStrokeText.isPresent()) {
652            result.append(' ');
653            if (canHtml) {
654                result.append("<font size='-2'>");
655            }
656            result.append('(').append(keyStrokeText.get()).append(')');
657            if (canHtml) {
658                result.append("</font>");
659            }
660        }
661        if (canHtml) {
662            result.append("&nbsp;</html>");
663        }
664        return result.toString();
665    }
666
667}