package chatty.util.hotkeys;
import chatty.Chatty;
import chatty.Logging;
import chatty.gui.MainGui;
import chatty.util.hotkeys.Hotkey.Type;
import chatty.util.settings.Settings;
import java.awt.KeyEventDispatcher;
import java.awt.KeyboardFocusManager;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.logging.Logger;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.ActionMap;
import javax.swing.InputMap;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JRootPane;
import javax.swing.KeyStroke;
/**
* Manage custom hotkeys. Can add regular, app-wide and global hotkeys and loads
* them from the settings/saves it to the settings when changed.
*
* @author tduva
*/
public class HotkeyManager {
private static final Logger LOGGER = Logger.getLogger(HotkeyManager.class.getName());
private static final String SETTING_NAME = "hotkeys";
/**
* What inputmap to use, so it's consistent between methods.
*/
private static final int INPUT_MAP_KEY = JComponent.WHEN_IN_FOCUSED_WINDOW;
/**
* Prefix for adding the action to input/action maps. This is searched for
* when removing all hotkeys.
*/
private static final String PREFIX = "chatty.util.hotkeys.";
private Settings settings;
private final MainGui main;
private final List<Hotkey> hotkeys = new ArrayList<>();
private final Map<String, HotkeyAction> actions = new LinkedHashMap<>();
private final Map<JDialog, Object> popouts = new WeakHashMap<>();
/**
* Whether global hotkeys are currently to be enabled (registered).
*/
private boolean globalHotkeysRegister = true;
/**
* Whether global hotkeys are currently enabled as per setting. They may
* still be not registered if temporarily disabled.
*/
private boolean globalHotkeysEnabled = false;
private boolean enabled = true;
private GlobalHotkeySetter globalHotkeys;
/**
* Warning text intended to be output to the user, about an error of the
* global hotkey feature. Should be set to null if warning was output, so
* it's only shown once.
*/
private String globalHotkeyErrorWarning;
public HotkeyManager(MainGui main) {
this.main = main;
if (Chatty.HOTKEY) {
try {
globalHotkeys = new GlobalHotkeySetter(new GlobalHotkeySetter.GlobalHotkeyListener() {
@Override
public void onHotkey(Object hotkeyId) {
onGlobalHotkey(hotkeyId);
}
});
// If an error occured during initialization, then set to null
// which means it's not going to be used.
if (!globalHotkeys.isInitalized()) {
globalHotkeyErrorWarning = globalHotkeys.getError();
globalHotkeys = null;
}
} catch (NoClassDefFoundError ex) {
LOGGER.warning("Failed to initialize hotkey setter [" + ex + "]");
globalHotkeyErrorWarning = "Failed to initialize global hotkeys (jintellitype-xx.jar not found).";
globalHotkeys = null;
}
}
KeyboardFocusManager kfm = KeyboardFocusManager.getCurrentKeyboardFocusManager();
kfm.addKeyEventDispatcher(new KeyEventDispatcher() {
@Override
public boolean dispatchKeyEvent(KeyEvent e) {
return applicationKeyTriggered(e);
}
});
}
public void setSettings(Settings settings) {
this.settings = settings;
}
/**
* Registers an action to be performed by a hotkey referring to the given
* id.
*
* @param id The id to be referred to by the hotkey
* @param label The label to be displayed to the user identifying the action
* @param action The action itself
*/
public void registerAction(String id, String label, Action action) {
HotkeyAction hotkeyAction = new HotkeyAction(id, label, action);
actions.put(id, hotkeyAction);
}
/**
* Register a popout window, so regular hotkeys can be added to it if
* necessary. References are saved in a WeakHashMap.
*
* @param popout
*/
public void registerPopout(JDialog popout) {
popouts.put(popout, null);
addHotkeys(popout.getRootPane());
}
/**
* Get a Map of action ids and their labels for display in the GUI.
*
* @return A Map with ids and their labels
*/
public Map<String, String> getActionsMap() {
Map<String, String> map = new LinkedHashMap<>();
for (HotkeyAction action : actions.values()) {
map.put(action.id, action.label);
}
return map;
}
/**
* Enable/disable global and app hotkeys.
*
* @param enabled
*/
public synchronized void setEnabled(boolean enabled) {
this.enabled = enabled;
if (!enabled || globalHotkeysEnabled) {
setGlobalHotkeysRegistered(enabled);
}
}
/**
* Enable or disable global hotkeys.
*
* @param enabled true to enable global hotkeys, false to disable
*/
public synchronized void setGlobalHotkeysEnabled(boolean enabled) {
globalHotkeysEnabled = enabled;
setGlobalHotkeysRegistered(enabled);
}
/**
* Enable or disable global hotkeys.
*
* @param enabled true to enable global hotkeys, false to disable
*/
private void setGlobalHotkeysRegistered(boolean register) {
boolean previous = globalHotkeysRegister;
this.globalHotkeysRegister = register;
if (register && !previous) {
addGlobalHotkeys();
}
if (!register && previous) {
removeGlobalHotkeys();
}
}
/**
* Replaces current hotkeys with the given ones. Hotkeys are removed and
* readded according to the new data.
*
* @param hotkeysData
*/
public synchronized void setData(Collection<Hotkey> hotkeysData) {
if (!hotkeys.equals(hotkeysData)) {
hotkeys.clear();
hotkeys.addAll(hotkeysData);
updateHotkeys();
saveToSettings();
}
}
/**
* Returns a copy of the current hotkeys. (The list is a defensive copy and
* can be modified, the Hotkey should be largely immutable, however the
* shouldExecuteAction() method shouldn't be called outside the EDT.)
*
* @return
*/
public synchronized List<Hotkey> getData() {
return new ArrayList<>(hotkeys);
}
/**
* Whether the global hotkey feature is available (version dependant and
* whether it has loaded proplery).
*
* @return
*/
public boolean globalHotkeysAvailable() {
return globalHotkeys != null;
}
/**
* Cleans up the global hotkeys.
*/
public void cleanUp() {
if (globalHotkeys != null) {
globalHotkeys.cleanUp();
}
}
/**
* Whether this Hotkey has an action registered.
*
* @param hotkey The Hotkey to check
* @return true if an action is registered, false otherwise
*/
private boolean doesHotkeyHaveAction(Hotkey hotkey) {
return actions.get(hotkey.actionId) != null;
}
/**
* Removes and reads all hotkeys according to the current data.
*/
private void updateHotkeys() {
removeAllHotkeys();
addHotkeys(null);
addGlobalHotkeys();
updateActions();
}
/**
* Adds all regular hotkeys to the given JRootPane, or to all (main and
* popouts) if the pane is null.
*
* @param pane The JRootPane to add the hotkeys to
*/
private void addHotkeys(JRootPane pane) {
for (Hotkey hotkey : hotkeys) {
if (doesHotkeyHaveAction(hotkey) && hotkey.type == Type.REGULAR) {
if (pane == null) {
addHotkey(hotkey, main.getRootPane());
for (JDialog popout : popouts.keySet()) {
addHotkey(hotkey, popout.getRootPane());
}
} else {
addHotkey(hotkey, pane);
}
}
}
}
/**
* Adds a single regular hotkey to the given JRootPane.
*
* @param hotkey
* @param pane
*/
private void addHotkey(Hotkey hotkey, JRootPane pane) {
String id = String.valueOf(hotkey.hashCode());
pane.getInputMap(INPUT_MAP_KEY).put(hotkey.keyStroke, PREFIX + id);
pane.getActionMap().put(PREFIX + id, createAction(hotkey));
}
/**
* Adds global hotkeys based on the current data, if global hotkeys are
* currently enabled.
*/
private void addGlobalHotkeys() {
if (!globalHotkeysRegister) {
return;
}
for (Hotkey hotkey : hotkeys) {
if (doesHotkeyHaveAction(hotkey) && hotkey.type == Type.GLOBAL) {
addGlobalHotkey(hotkey);
}
}
}
/**
* Adds a single global hotkey.
*
* @param hotkey
*/
private void addGlobalHotkey(Hotkey hotkey) {
if (globalHotkeys != null) {
globalHotkeys.registerHotkey(hotkey,
hotkey.keyStroke.getModifiers(),
hotkey.keyStroke.getKeyCode());
}
}
/**
* Removes all hotkeys that have to be registered (regular and global).
*/
private void removeAllHotkeys() {
removeHotkeys(main.getRootPane());
for (JDialog popout : popouts.keySet()) {
removeHotkeys(popout.getRootPane());
}
removeGlobalHotkeys();
removeHotkeysFromActions();
}
/**
* Removes all global hotkeys.
*/
private void removeGlobalHotkeys() {
if (globalHotkeys != null) {
globalHotkeys.unregisterAllHotkeys();
}
}
/**
* Removes all entries from the InputMap that point to an action with the
* prefix of this.
*
* @param pane The JRootPane to remove the hotkeys from
*/
private void removeHotkeys(JRootPane pane) {
Set<KeyStroke> toBeRemoved = new HashSet<>();
InputMap input = pane.getInputMap(INPUT_MAP_KEY);
ActionMap action = pane.getActionMap();
if (input.keys() == null) {
return;
}
for (KeyStroke keyStroke : input.keys()) {
Object key = input.get(keyStroke);
if (key instanceof String && ((String)key).startsWith(PREFIX)) {
toBeRemoved.add(keyStroke);
action.remove(key);
}
}
for (KeyStroke keyStroke : toBeRemoved) {
input.remove(keyStroke);
}
}
/**
* Removes the hotkeys from all actions, so only those that are still set
* are readded.
*/
private void removeHotkeysFromActions() {
for (HotkeyAction action : actions.values()) {
action.action.putValue(Action.ACCELERATOR_KEY, null);
}
}
/**
* Turns all current hotkeys into something that can be written to the
* settings.
*/
private synchronized void saveToSettings() {
if (settings != null) {
List<List> dataToSave = new ArrayList<>();
for (Hotkey hotkey : hotkeys) {
dataToSave.add(hotkeyToList(hotkey));
}
settings.putList(SETTING_NAME, dataToSave);
}
}
/**
* Removes all current hotkeys and loads the data from the settings.
*
* @param settings
*/
public synchronized void loadFromSettings(Settings settings) {
this.settings = settings;
List<List> loadFrom = settings.getList(SETTING_NAME);
hotkeys.clear();
for (List l : loadFrom) {
Hotkey entry = listToHotkey(l);
if (entry != null) {
hotkeys.add(entry);
}
}
updateHotkeys();
checkGlobalHotkeyWarning();
}
/**
* Turns a Hotkey into a List to save in the settings.
*
* @param hotkey
* @return
*/
private List hotkeyToList(Hotkey hotkey) {
List l = new ArrayList();
l.add(hotkey.actionId);
l.add(hotkey.keyStroke.toString());
l.add(hotkey.type.id);
l.add(hotkey.custom);
l.add(hotkey.delay);
return l;
}
/**
* Turns a List from the settings into a Hotkey.
*
* @param list
* @return
*/
private Hotkey listToHotkey(List list) {
try {
String actionId = (String)list.get(0);
KeyStroke keyStroke = KeyStroke.getKeyStroke((String)list.get(1));
if (keyStroke == null) {
LOGGER.warning("Error loading hotkey, invalid: "+list);
return null;
}
// Optional data with default values
Type type = Hotkey.Type.REGULAR;
String custom = "";
int delay = 0;
if (list.size() > 2) {
type = Hotkey.Type.getTypeFromId(((Number)list.get(2)).intValue());
}
if (list.size() > 3) {
custom = (String)list.get(3);
}
if (list.size() > 4) {
delay = ((Number)list.get(4)).intValue();
}
return new Hotkey(actionId, keyStroke, type, custom, delay);
} catch (IndexOutOfBoundsException | NullPointerException | ClassCastException ex) {
LOGGER.warning("Error loading hotkey: "+list+" ["+ex+"]");
return null;
}
}
/**
* Output warning of error when initializing global hotkey feature. Only
* output once and only when a global hotkey is currently configured.
*/
private void checkGlobalHotkeyWarning() {
if (globalHotkeyErrorWarning == null) {
return;
}
for (Hotkey hotkey : hotkeys) {
if (doesHotkeyHaveAction(hotkey) && hotkey.type == Type.GLOBAL) {
LOGGER.log(Logging.USERINFO, globalHotkeyErrorWarning+" "
+ "[You are getting this message because you have a "
+ "global hotkey configured. If you don't use it you "
+ "can ignore this warning.]");
globalHotkeyErrorWarning = null;
return;
}
}
}
/**
* Creates a bridge Action which calls the actual registered action for this
* hotkey, adding some more information.
*
* @param hotkey
* @return
*/
private Action createAction(final Hotkey hotkey) {
final HotkeyAction hotkeyAction = actions.get(hotkey.actionId);
if (hotkeyAction == null) {
return null;
}
return new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (enabled && hotkey.shouldExecuteAction()) {
hotkeyAction.action.actionPerformed(new ActionEvent(hotkeyAction, 0, hotkey.custom));
}
}
};
}
private void updateActions() {
for (Hotkey hotkey : hotkeys) {
updateAction(hotkey);
}
}
private void updateAction(final Hotkey hotkey) {
final HotkeyAction hotkeyAction = actions.get(hotkey.actionId);
if (hotkeyAction == null) {
return;
}
hotkeyAction.action.putValue(Action.ACCELERATOR_KEY, hotkey.keyStroke);
}
/**
* Called when a global hotkey is executed.
*
* @param hotkeyId
*/
private void onGlobalHotkey(Object hotkeyId) {
Hotkey hotkey = (Hotkey)hotkeyId;
HotkeyAction action = actions.get(hotkey.actionId);
if (enabled && action != null && hotkey.shouldExecuteAction()) {
action.action.actionPerformed(new ActionEvent(action, 0, hotkey.custom));
}
}
/**
* Called when a key event is triggered anywhere in the application. This
* doesn't have to be a Hotkey defined here, so check if it is first.
*
* @param e
* @return
*/
private boolean applicationKeyTriggered(KeyEvent e) {
if (!enabled) {
return false;
}
KeyStroke keyStroke = KeyStroke.getKeyStrokeForEvent(e);
for (Hotkey hotkey : hotkeys) {
if (hotkey.type == Hotkey.Type.APPLICATION && hotkey.keyStroke.equals(keyStroke)) {
HotkeyAction action = actions.get(hotkey.actionId);
if (action != null && hotkey.shouldExecuteAction()) {
action.action.actionPerformed(new ActionEvent(action, 0, hotkey.custom));
return true;
}
}
}
return false;
}
}