001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.preferences.shortcut;
003
004import static org.openstreetmap.josm.tools.I18n.marktr;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.Color;
008import java.awt.Component;
009import java.awt.Dimension;
010import java.awt.GridBagConstraints;
011import java.awt.GridBagLayout;
012import java.awt.GridLayout;
013import java.awt.Insets;
014import java.awt.Toolkit;
015import java.awt.event.KeyEvent;
016import java.lang.reflect.Field;
017import java.util.ArrayList;
018import java.util.LinkedHashMap;
019import java.util.List;
020import java.util.Map;
021import java.util.regex.PatternSyntaxException;
022
023import javax.swing.AbstractAction;
024import javax.swing.BorderFactory;
025import javax.swing.BoxLayout;
026import javax.swing.DefaultComboBoxModel;
027import javax.swing.JCheckBox;
028import javax.swing.JLabel;
029import javax.swing.JPanel;
030import javax.swing.JScrollPane;
031import javax.swing.JTable;
032import javax.swing.KeyStroke;
033import javax.swing.ListSelectionModel;
034import javax.swing.RowFilter;
035import javax.swing.SwingConstants;
036import javax.swing.UIManager;
037import javax.swing.event.DocumentEvent;
038import javax.swing.event.DocumentListener;
039import javax.swing.event.ListSelectionEvent;
040import javax.swing.event.ListSelectionListener;
041import javax.swing.table.AbstractTableModel;
042import javax.swing.table.DefaultTableCellRenderer;
043import javax.swing.table.TableColumnModel;
044import javax.swing.table.TableModel;
045import javax.swing.table.TableRowSorter;
046
047import org.openstreetmap.josm.Main;
048import org.openstreetmap.josm.data.preferences.ColorProperty;
049import org.openstreetmap.josm.gui.util.GuiHelper;
050import org.openstreetmap.josm.gui.widgets.JosmComboBox;
051import org.openstreetmap.josm.gui.widgets.JosmTextField;
052import org.openstreetmap.josm.gui.widgets.SelectAllOnFocusGainedDecorator;
053import org.openstreetmap.josm.tools.Shortcut;
054
055/**
056 * This is the keyboard preferences content.
057 */
058public class PrefJPanel extends JPanel {
059
060    // table of shortcuts
061    private final AbstractTableModel model;
062    // this are the display(!) texts for the checkboxes. Let the JVM do the i18n for us <g>.
063    // Ok, there's a real reason for this: The JVM should know best how the keys are labelled
064    // on the physical keyboard. What language pack is installed in JOSM is completely
065    // independent from the keyboard's labelling. But the operation system's locale
066    // usually matches the keyboard. This even works with my English Windows and my German keyboard.
067    private static final String SHIFT = KeyEvent.getKeyModifiersText(KeyStroke.getKeyStroke(KeyEvent.VK_A,
068            KeyEvent.SHIFT_DOWN_MASK).getModifiers());
069    private static final String CTRL = KeyEvent.getKeyModifiersText(KeyStroke.getKeyStroke(KeyEvent.VK_A,
070            KeyEvent.CTRL_DOWN_MASK).getModifiers());
071    private static final String ALT = KeyEvent.getKeyModifiersText(KeyStroke.getKeyStroke(KeyEvent.VK_A,
072            KeyEvent.ALT_DOWN_MASK).getModifiers());
073    private static final String META = KeyEvent.getKeyModifiersText(KeyStroke.getKeyStroke(KeyEvent.VK_A,
074            KeyEvent.META_DOWN_MASK).getModifiers());
075
076    // A list of keys to present the user. Sadly this really is a list of keys Java knows about,
077    // not a list of real physical keys. If someone knows how to get that list?
078    private static Map<Integer, String> keyList = setKeyList();
079
080    private final JCheckBox cbAlt = new JCheckBox();
081    private final JCheckBox cbCtrl = new JCheckBox();
082    private final JCheckBox cbMeta = new JCheckBox();
083    private final JCheckBox cbShift = new JCheckBox();
084    private final JCheckBox cbDefault = new JCheckBox();
085    private final JCheckBox cbDisable = new JCheckBox();
086    private final JosmComboBox<String> tfKey = new JosmComboBox<>();
087
088    private final JTable shortcutTable = new JTable();
089
090    private final JosmTextField filterField = new JosmTextField();
091
092    /** Creates new form prefJPanel */
093    public PrefJPanel() {
094        this.model = new ScListModel();
095        initComponents();
096    }
097
098    private static Map<Integer, String> setKeyList() {
099        Map<Integer, String> list = new LinkedHashMap<>();
100        String unknown = Toolkit.getProperty("AWT.unknown", "Unknown");
101        // Assume all known keys are declared in KeyEvent as "public static int VK_*"
102        for (Field field : KeyEvent.class.getFields()) {
103            if (field.getName().startsWith("VK_")) {
104                try {
105                    int i = field.getInt(null);
106                    String s = KeyEvent.getKeyText(i);
107                    if (s != null && s.length() > 0 && !s.contains(unknown)) {
108                        list.put(Integer.valueOf(i), s);
109                    }
110                } catch (IllegalArgumentException | IllegalAccessException e) {
111                    Main.error(e);
112                }
113            }
114        }
115        list.put(Integer.valueOf(-1), "");
116        return list;
117    }
118
119    /**
120     * Show only shortcuts with descriptions containing given substring
121     * @param substring The substring used to filter
122     */
123    public void filter(String substring) {
124        filterField.setText(substring);
125    }
126
127    private static class ScListModel extends AbstractTableModel {
128        private final String[] columnNames = new String[]{tr("Action"), tr("Shortcut")};
129        private final transient List<Shortcut> data;
130
131        /**
132         * Constructs a new {@code ScListModel}.
133         */
134        ScListModel() {
135            data = Shortcut.listAll();
136        }
137
138        @Override
139        public int getColumnCount() {
140            return columnNames.length;
141        }
142
143        @Override
144        public int getRowCount() {
145            return data.size();
146        }
147
148        @Override
149        public String getColumnName(int col) {
150            return columnNames[col];
151        }
152
153        @Override
154        public Object getValueAt(int row, int col) {
155            return (col == 0) ? data.get(row).getLongText() : data.get(row);
156        }
157    }
158
159    private class ShortcutTableCellRenderer extends DefaultTableCellRenderer {
160
161        private final transient ColorProperty SHORTCUT_BACKGROUND_USER_COLOR = new ColorProperty(
162                marktr("Shortcut Background: User"),
163                new Color(200, 255, 200));
164        private final transient ColorProperty SHORTCUT_BACKGROUND_MODIFIED_COLOR = new ColorProperty(
165                marktr("Shortcut Background: Modified"),
166                new Color(255, 255, 200));
167
168        private final boolean name;
169
170        ShortcutTableCellRenderer(boolean name) {
171            this.name = name;
172        }
173
174        @Override
175        public Component getTableCellRendererComponent(JTable table, Object value, boolean
176                isSelected, boolean hasFocus, int row, int column) {
177            int row1 = shortcutTable.convertRowIndexToModel(row);
178            Shortcut sc = (Shortcut) model.getValueAt(row1, -1);
179            if (sc == null)
180                return null;
181            JLabel label = (JLabel) super.getTableCellRendererComponent(
182                table, name ? sc.getLongText() : sc.getKeyText(), isSelected, hasFocus, row, column);
183            GuiHelper.setBackgroundReadable(label, UIManager.getColor("Table.background"));
184            if (sc.isAssignedUser()) {
185                GuiHelper.setBackgroundReadable(label, SHORTCUT_BACKGROUND_USER_COLOR.get());
186            } else if (!sc.isAssignedDefault()) {
187                GuiHelper.setBackgroundReadable(label, SHORTCUT_BACKGROUND_MODIFIED_COLOR.get());
188            }
189            return label;
190        }
191    }
192
193    private void initComponents() {
194        CbAction action = new CbAction(this);
195        setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
196        add(buildFilterPanel());
197
198        // This is the list of shortcuts:
199        shortcutTable.setModel(model);
200        shortcutTable.getSelectionModel().addListSelectionListener(action);
201        shortcutTable.setFillsViewportHeight(true);
202        shortcutTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
203        shortcutTable.setAutoCreateRowSorter(true);
204        TableColumnModel mod = shortcutTable.getColumnModel();
205        mod.getColumn(0).setCellRenderer(new ShortcutTableCellRenderer(true));
206        mod.getColumn(1).setCellRenderer(new ShortcutTableCellRenderer(false));
207        JScrollPane listScrollPane = new JScrollPane();
208        listScrollPane.setViewportView(shortcutTable);
209
210        JPanel listPane = new JPanel(new GridLayout());
211        listPane.add(listScrollPane);
212        add(listPane);
213
214        // and here follows the edit area. I won't object to someone re-designing it, it looks, um, "minimalistic" ;)
215
216        cbDefault.setAction(action);
217        cbDefault.setText(tr("Use default"));
218        cbShift.setAction(action);
219        cbShift.setText(SHIFT); // see above for why no tr()
220        cbDisable.setAction(action);
221        cbDisable.setText(tr("Disable"));
222        cbCtrl.setAction(action);
223        cbCtrl.setText(CTRL); // see above for why no tr()
224        cbAlt.setAction(action);
225        cbAlt.setText(ALT); // see above for why no tr()
226        tfKey.setAction(action);
227        tfKey.setModel(new DefaultComboBoxModel<>(keyList.values().toArray(new String[keyList.size()])));
228        cbMeta.setAction(action);
229        cbMeta.setText(META); // see above for why no tr()
230
231        JPanel shortcutEditPane = new JPanel(new GridLayout(5, 2));
232
233        shortcutEditPane.add(cbDefault);
234        shortcutEditPane.add(new JLabel());
235        shortcutEditPane.add(cbShift);
236        shortcutEditPane.add(cbDisable);
237        shortcutEditPane.add(cbCtrl);
238        shortcutEditPane.add(new JLabel(tr("Key:"), SwingConstants.LEFT));
239        shortcutEditPane.add(cbAlt);
240        shortcutEditPane.add(tfKey);
241        shortcutEditPane.add(cbMeta);
242
243        shortcutEditPane.add(new JLabel(tr("Attention: Use real keyboard keys only!")));
244
245        action.actionPerformed(null); // init checkboxes
246
247        add(shortcutEditPane);
248    }
249
250    private JPanel buildFilterPanel() {
251        // copied from PluginPreference
252        JPanel pnl = new JPanel(new GridBagLayout());
253        pnl.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
254        GridBagConstraints gc = new GridBagConstraints();
255
256        gc.anchor = GridBagConstraints.NORTHWEST;
257        gc.fill = GridBagConstraints.HORIZONTAL;
258        gc.weightx = 0.0;
259        gc.insets = new Insets(0, 0, 0, 5);
260        pnl.add(new JLabel(tr("Search:")), gc);
261
262        gc.gridx = 1;
263        gc.weightx = 1.0;
264        pnl.add(filterField, gc);
265        filterField.setToolTipText(tr("Enter a search expression"));
266        SelectAllOnFocusGainedDecorator.decorate(filterField);
267        filterField.getDocument().addDocumentListener(new FilterFieldAdapter());
268        pnl.setMaximumSize(new Dimension(300, 10));
269        return pnl;
270    }
271
272    // this allows to edit shortcuts. it:
273    //  * sets the edit controls to the selected shortcut
274    //  * enabled/disables the controls as needed
275    //  * writes the user's changes to the shortcut
276    // And after I finally had it working, I realized that those two methods
277    // are playing ping-pong (politically correct: table tennis, I know) and
278    // even have some duplicated code. Feel free to refactor, If you have
279    // more experience with GUI coding than I have.
280    private static class CbAction extends AbstractAction implements ListSelectionListener {
281        private final PrefJPanel panel;
282
283        CbAction(PrefJPanel panel) {
284            this.panel = panel;
285        }
286
287        private void disableAllModifierCheckboxes() {
288            panel.cbDefault.setEnabled(false);
289            panel.cbDisable.setEnabled(false);
290            panel.cbShift.setEnabled(false);
291            panel.cbCtrl.setEnabled(false);
292            panel.cbAlt.setEnabled(false);
293            panel.cbMeta.setEnabled(false);
294        }
295
296        @Override
297        public void valueChanged(ListSelectionEvent e) {
298            ListSelectionModel lsm = panel.shortcutTable.getSelectionModel(); // can't use e here
299            if (!lsm.isSelectionEmpty()) {
300                int row = panel.shortcutTable.convertRowIndexToModel(lsm.getMinSelectionIndex());
301                Shortcut sc = (Shortcut) panel.model.getValueAt(row, -1);
302                panel.cbDefault.setSelected(!sc.isAssignedUser());
303                panel.cbDisable.setSelected(sc.getKeyStroke() == null);
304                panel.cbShift.setSelected(sc.getAssignedModifier() != -1 && (sc.getAssignedModifier() & KeyEvent.SHIFT_DOWN_MASK) != 0);
305                panel.cbCtrl.setSelected(sc.getAssignedModifier() != -1 && (sc.getAssignedModifier() & KeyEvent.CTRL_DOWN_MASK) != 0);
306                panel.cbAlt.setSelected(sc.getAssignedModifier() != -1 && (sc.getAssignedModifier() & KeyEvent.ALT_DOWN_MASK) != 0);
307                panel.cbMeta.setSelected(sc.getAssignedModifier() != -1 && (sc.getAssignedModifier() & KeyEvent.META_DOWN_MASK) != 0);
308                if (sc.getKeyStroke() != null) {
309                    panel.tfKey.setSelectedItem(keyList.get(sc.getKeyStroke().getKeyCode()));
310                } else {
311                    panel.tfKey.setSelectedItem(keyList.get(-1));
312                }
313                if (!sc.isChangeable()) {
314                    disableAllModifierCheckboxes();
315                    panel.tfKey.setEnabled(false);
316                } else {
317                    panel.cbDefault.setEnabled(true);
318                    actionPerformed(null);
319                }
320                panel.model.fireTableRowsUpdated(row, row);
321            } else {
322                disableAllModifierCheckboxes();
323                panel.tfKey.setEnabled(false);
324            }
325        }
326
327        @Override
328        public void actionPerformed(java.awt.event.ActionEvent e) {
329            ListSelectionModel lsm = panel.shortcutTable.getSelectionModel();
330            if (lsm != null && !lsm.isSelectionEmpty()) {
331                if (e != null) { // only if we've been called by a user action
332                    int row = panel.shortcutTable.convertRowIndexToModel(lsm.getMinSelectionIndex());
333                    Shortcut sc = (Shortcut) panel.model.getValueAt(row, -1);
334                    if (panel.cbDisable.isSelected()) {
335                        sc.setAssignedModifier(-1);
336                    } else if (panel.tfKey.getSelectedItem() == null || "".equals(panel.tfKey.getSelectedItem())) {
337                        sc.setAssignedModifier(KeyEvent.VK_CANCEL);
338                    } else {
339                        sc.setAssignedModifier(
340                                (panel.cbShift.isSelected() ? KeyEvent.SHIFT_DOWN_MASK : 0) |
341                                (panel.cbCtrl.isSelected() ? KeyEvent.CTRL_DOWN_MASK : 0) |
342                                (panel.cbAlt.isSelected() ? KeyEvent.ALT_DOWN_MASK : 0) |
343                                (panel.cbMeta.isSelected() ? KeyEvent.META_DOWN_MASK : 0)
344                        );
345                        for (Map.Entry<Integer, String> entry : keyList.entrySet()) {
346                            if (entry.getValue().equals(panel.tfKey.getSelectedItem())) {
347                                sc.setAssignedKey(entry.getKey());
348                            }
349                        }
350                    }
351                    sc.setAssignedUser(!panel.cbDefault.isSelected());
352                    valueChanged(null);
353                }
354                boolean state = !panel.cbDefault.isSelected();
355                panel.cbDisable.setEnabled(state);
356                state = state && !panel.cbDisable.isSelected();
357                panel.cbShift.setEnabled(state);
358                panel.cbCtrl.setEnabled(state);
359                panel.cbAlt.setEnabled(state);
360                panel.cbMeta.setEnabled(state);
361                panel.tfKey.setEnabled(state);
362            } else {
363                disableAllModifierCheckboxes();
364                panel.tfKey.setEnabled(false);
365            }
366        }
367    }
368
369    class FilterFieldAdapter implements DocumentListener {
370        private void filter() {
371            String expr = filterField.getText().trim();
372            if (expr.isEmpty()) {
373                expr = null;
374            }
375            try {
376                final TableRowSorter<? extends TableModel> sorter =
377                    (TableRowSorter<? extends TableModel>) shortcutTable.getRowSorter();
378                if (expr == null) {
379                    sorter.setRowFilter(null);
380                } else {
381                    expr = expr.replace("+", "\\+");
382                    // split search string on whitespace, do case-insensitive AND search
383                    List<RowFilter<Object, Object>> andFilters = new ArrayList<>();
384                    for (String word : expr.split("\\s+")) {
385                        andFilters.add(RowFilter.regexFilter("(?i)" + word));
386                    }
387                    sorter.setRowFilter(RowFilter.andFilter(andFilters));
388                }
389                model.fireTableDataChanged();
390            } catch (PatternSyntaxException | ClassCastException ex) {
391                Main.warn(ex);
392            }
393        }
394
395        @Override
396        public void changedUpdate(DocumentEvent e) {
397            filter();
398        }
399
400        @Override
401        public void insertUpdate(DocumentEvent e) {
402            filter();
403        }
404
405        @Override
406        public void removeUpdate(DocumentEvent e) {
407            filter();
408        }
409    }
410}