From d53851342876b06cae57b185abb9649998aac16c Mon Sep 17 00:00:00 2001 From: dragonmacher <48328597+dragonmacher@users.noreply.github.com> Date: Thu, 4 Sep 2025 17:02:11 -0400 Subject: [PATCH] GP-5967 - Improved Options Key Binding UI --- .../plugintool/dialog/KeyBindingsTest.java | 4 +- .../Docking/data/docking.theme.properties | 2 + .../main/java/docking/ActionBindingPanel.java | 57 ++-- .../src/main/java/docking/KeyEntryPanel.java | 4 +- .../main/java/docking/KeyEntryTextField.java | 18 +- .../java/docking/MouseEntryTextField.java | 27 +- .../java/docking/actions/KeyBindingUtils.java | 4 +- .../docking/actions/SetKeyBindingAction.java | 6 +- .../java/docking/actions/ToolActions.java | 6 +- .../java/docking/widgets/MultiLineLabel.java | 90 +++-- .../widgets/filter/ClearFilterLabel.java | 6 +- .../plugintool/dialog/KeyBindingsPanel.java | 317 +++++++++--------- 12 files changed, 281 insertions(+), 260 deletions(-) diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/plugintool/dialog/KeyBindingsTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/plugintool/dialog/KeyBindingsTest.java index 03e80c4f71..e2383fee90 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/plugintool/dialog/KeyBindingsTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/plugintool/dialog/KeyBindingsTest.java @@ -70,7 +70,7 @@ public class KeyBindingsTest extends AbstractGhidraHeadedIntegrationTest { setUpDialog(); - grabActionsWithoutKeybinding(); + grabActionsWithoutKeyBinding(); } @After @@ -545,7 +545,7 @@ public class KeyBindingsTest extends AbstractGhidraHeadedIntegrationTest { } // find 2 actions that do not have key bindings so that we can add and change the values - private void grabActionsWithoutKeybinding() { + private void grabActionsWithoutKeyBinding() { Set list = tool.getAllActions(); for (DockingActionIf action : list) { if (ignoreAction(action)) { diff --git a/Ghidra/Framework/Docking/data/docking.theme.properties b/Ghidra/Framework/Docking/data/docking.theme.properties index 584c09e7d6..919536d3c9 100644 --- a/Ghidra/Framework/Docking/data/docking.theme.properties +++ b/Ghidra/Framework/Docking/data/docking.theme.properties @@ -123,6 +123,8 @@ icon.widget.imagepanel.zoom.out = icon.zoom.out icon.widget.filterpanel.filter.off = filter_off.png icon.widget.filterpanel.filter.on = filter_on.png +icon.text.field.clear = icon.delete[size(12,12)] + icon.widget.pathmanager.reset = trash-empty.png icon.widget.table.header.help = info_small.png diff --git a/Ghidra/Framework/Docking/src/main/java/docking/ActionBindingPanel.java b/Ghidra/Framework/Docking/src/main/java/docking/ActionBindingPanel.java index 4cc64eca82..bc83fdd188 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/ActionBindingPanel.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/ActionBindingPanel.java @@ -15,12 +15,13 @@ */ package docking; -import java.awt.BorderLayout; import java.util.Objects; import javax.swing.*; -import docking.widgets.checkbox.GCheckBox; +import docking.widgets.EmptyBorderButton; +import docking.widgets.label.GLabel; +import generic.theme.GIcon; import gui.event.MouseBinding; /** @@ -31,9 +32,7 @@ public class ActionBindingPanel extends JPanel { private static final String DISABLED_HINT = "Select an action"; private KeyEntryPanel keyEntryPanel; - private JCheckBox useMouseBindingCheckBox; private MouseEntryTextField mouseEntryField; - private JPanel textFieldPanel; private DockingActionInputBindingListener listener; @@ -47,42 +46,31 @@ public class ActionBindingPanel extends JPanel { setLayout(new BoxLayout(this, BoxLayout.LINE_AXIS)); - textFieldPanel = new JPanel(new BorderLayout()); - keyEntryPanel = new KeyEntryPanel(20, ks -> listener.keyStrokeChanged(ks)); keyEntryPanel.setDisabledHint(DISABLED_HINT); keyEntryPanel.setEnabled(false); // enabled on action selection + mouseEntryField = new MouseEntryTextField(20, mb -> listener.mouseBindingChanged(mb)); mouseEntryField.setDisabledHint(DISABLED_HINT); - mouseEntryField.setEnabled(false); // enabled on action selection - textFieldPanel.add(keyEntryPanel, BorderLayout.NORTH); + JButton clearMouseButton = new EmptyBorderButton(new GIcon("icon.text.field.clear")); + clearMouseButton.setName("Clear Mouse Binding"); + clearMouseButton.addActionListener(e -> mouseEntryField.clearMouseBinding()); - String checkBoxText = "Enter Mouse Binding"; - useMouseBindingCheckBox = new GCheckBox(checkBoxText); - useMouseBindingCheckBox - .setToolTipText("When checked, the text field accepts mouse buttons"); - useMouseBindingCheckBox.setName(checkBoxText); - useMouseBindingCheckBox.addItemListener(e -> updateTextField()); + GLabel keyBindingLabel = new GLabel("Key Binding: "); + JTextField tf = keyEntryPanel.getTextField(); + keyBindingLabel.setLabelFor(tf); - add(textFieldPanel); - add(Box.createHorizontalStrut(5)); - add(useMouseBindingCheckBox); - } + GLabel mouseBindingLabel = new GLabel("Mouse Binding: "); + mouseBindingLabel.setLabelFor(mouseBindingLabel); - private void updateTextField() { - - if (useMouseBindingCheckBox.isSelected()) { - textFieldPanel.remove(keyEntryPanel); - textFieldPanel.add(mouseEntryField, BorderLayout.NORTH); - } - else { - textFieldPanel.remove(mouseEntryField); - textFieldPanel.add(keyEntryPanel, BorderLayout.NORTH); - } - - validate(); - repaint(); + add(keyBindingLabel); + add(keyEntryPanel); + add(Box.createHorizontalStrut(30)); + add(mouseBindingLabel); + add(mouseEntryField); + add(Box.createHorizontalStrut(2)); + add(clearMouseButton); } public void setKeyBindingData(KeyStroke ks, MouseBinding mb) { @@ -113,11 +101,6 @@ public class ActionBindingPanel extends JPanel { } public void clearMouseBinding() { - mouseEntryField.clearField(); + mouseEntryField.clearMouseBinding(); } - - public boolean isMouseBinding() { - return useMouseBindingCheckBox.isSelected(); - } - } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/KeyEntryPanel.java b/Ghidra/Framework/Docking/src/main/java/docking/KeyEntryPanel.java index 6fac267970..c265b5b891 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/KeyEntryPanel.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/KeyEntryPanel.java @@ -18,7 +18,7 @@ package docking; import javax.swing.*; import docking.widgets.EmptyBorderButton; -import resources.Icons; +import generic.theme.GIcon; /** * A panel that holds a {@link KeyEntryTextField} and a button for clearing the current key binding. @@ -41,7 +41,7 @@ public class KeyEntryPanel extends JPanel { setLayout(new BoxLayout(this, BoxLayout.LINE_AXIS)); keyEntryField = new KeyEntryTextField(columns, listener); - clearButton = new EmptyBorderButton(Icons.DELETE_ICON); + clearButton = new EmptyBorderButton(new GIcon("icon.text.field.clear")); clearButton.setName("Clear Key Binding"); clearButton.addActionListener(e -> keyEntryField.clearKeyStroke()); diff --git a/Ghidra/Framework/Docking/src/main/java/docking/KeyEntryTextField.java b/Ghidra/Framework/Docking/src/main/java/docking/KeyEntryTextField.java index dad11ec184..a37f025dc8 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/KeyEntryTextField.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/KeyEntryTextField.java @@ -15,8 +15,8 @@ */ package docking; -import java.awt.event.KeyEvent; -import java.awt.event.KeyListener; +import java.awt.event.*; +import java.awt.event.FocusEvent.Cause; import java.util.Objects; import javax.swing.KeyStroke; @@ -47,7 +47,21 @@ public class KeyEntryTextField extends HintTextField { getAccessibleContext().setAccessibleName(getName()); setColumns(columns); this.listener = listener; + + // remove the default mouse listeners to prevent pasting + MouseListener[] oldListeners1 = getMouseListeners(); + for (MouseListener l : oldListeners1) { + removeMouseListener(l); + } + addKeyListener(new MyKeyListener()); + + addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + requestFocusInWindow(Cause.MOUSE_EVENT); + } + }); } @Override diff --git a/Ghidra/Framework/Docking/src/main/java/docking/MouseEntryTextField.java b/Ghidra/Framework/Docking/src/main/java/docking/MouseEntryTextField.java index 5ee2e14722..31d0536911 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/MouseEntryTextField.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/MouseEntryTextField.java @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -16,6 +16,7 @@ package docking; import java.awt.event.*; +import java.awt.event.FocusEvent.Cause; import java.util.Objects; import java.util.function.Consumer; @@ -37,6 +38,12 @@ public class MouseEntryTextField extends HintTextField { getAccessibleContext().setAccessibleName(getName()); this.listener = Objects.requireNonNull(listener); + // remove the default mouse listeners to prevent pasting + MouseListener[] oldListeners1 = getMouseListeners(); + for (MouseListener l : oldListeners1) { + removeMouseListener(l); + } + addMouseListener(new MyMouseListener()); addKeyListener(new MyKeyListener()); } @@ -63,10 +70,23 @@ public class MouseEntryTextField extends HintTextField { processMouseBinding(mb, false); } + /** + * Clears the state of this class, but does not notify listeners. This allows clients to + * control the state of the field without having a callback change the client state. + */ public void clearField() { processMouseBinding(null, false); } + /** + * Clears the state of this class and notifies this client. This effectively allows for the + * programmatic setting of the mouse binding in use to be null, or in the 'no mouse binding set' + * state. + */ + public void clearMouseBinding() { + processMouseBinding(null, true); + } + private void processMouseBinding(MouseBinding mb, boolean notify) { this.mouseBinding = mb; @@ -90,6 +110,8 @@ public class MouseEntryTextField extends HintTextField { return; } + requestFocusInWindow(Cause.MOUSE_EVENT); + int modifiersEx = e.getModifiersEx(); int button = e.getButton(); @@ -102,6 +124,7 @@ public class MouseEntryTextField extends HintTextField { } processMouseBinding(new MouseBinding(button, modifiersEx), true); + e.consume(); } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/actions/KeyBindingUtils.java b/Ghidra/Framework/Docking/src/main/java/docking/actions/KeyBindingUtils.java index 5851a9238f..6d64c1a88a 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/actions/KeyBindingUtils.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/actions/KeyBindingUtils.java @@ -94,7 +94,7 @@ public class KeyBindingUtils { public static ToolOptions importKeyBindings() { // show a filechooser for the user to choose a location InputStream inputStream = getInputStreamForFile(getStartingDir()); - return createOptionsforKeybindings(inputStream); + return createOptionsforKeyBindings(inputStream); } /** @@ -107,7 +107,7 @@ public class KeyBindingUtils { * @return An options object that is composed of key binding names and their * associated keystrokes. */ - public static ToolOptions createOptionsforKeybindings(InputStream inputStream) { + public static ToolOptions createOptionsforKeyBindings(InputStream inputStream) { if (inputStream == null) { return null; } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/actions/SetKeyBindingAction.java b/Ghidra/Framework/Docking/src/main/java/docking/actions/SetKeyBindingAction.java index 0321556538..86179c0583 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/actions/SetKeyBindingAction.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/actions/SetKeyBindingAction.java @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -54,7 +54,7 @@ public class SetKeyBindingAction extends DockingAction { if (!action.getKeyBindingType().supportsKeyBindings()) { Component parent = windowManager.getActiveComponent(); - Msg.showInfo(getClass(), parent, "Unable to Set Keybinding", + Msg.showInfo(getClass(), parent, "Unable to Set Key Binding", "Action \"" + getActionName(action) + "\" does not support key bindings"); return; } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/actions/ToolActions.java b/Ghidra/Framework/Docking/src/main/java/docking/actions/ToolActions.java index 3ce3f3cde8..d49142f86a 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/actions/ToolActions.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/actions/ToolActions.java @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -241,7 +241,7 @@ public class ToolActions implements DockingToolActions, PropertyChangeListener { private void loadKeyBindingFromOptions(DockingActionIf action, ActionTrigger actionTrigger) { String fullName = action.getFullName(); - String description = "Keybinding for " + fullName; + String description = "Key Binding for " + fullName; options.registerOption(fullName, OptionType.ACTION_TRIGGER, actionTrigger, null, description); diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/MultiLineLabel.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/MultiLineLabel.java index 704ff3cca8..443eac30e0 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/MultiLineLabel.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/MultiLineLabel.java @@ -26,7 +26,7 @@ import utilities.util.reflection.ReflectionUtilities; /** * - * Class to render a String that has new line characters as a multiline + * Class to render a String that has new line characters as a multi-line * label. Calculates the resizing and centering characteristics. *

* Not affected by HTML formatting. @@ -49,12 +49,19 @@ public class MultiLineLabel extends JPanel { protected String[] lines; // lines to text to display protected int num_lines; // number of lines protected int margin_width; // left and right margins - protected int margin_height; // top and botton margins + protected int margin_height; // top and bottom margins protected int line_height; // total height of font protected int line_ascent; // font height above baseline protected int[] line_widths; // how wide each line is protected int max_width; // width of widest line protected int alignment = CENTER; // default alignment of text + private VerticalAlignment verticalAlignment = VerticalAlignment.MIDDLE; + + /** Values for controlling vertical alignment of the text */ + public enum VerticalAlignment { + TOP, + MIDDLE; + } /** * Default constructor. @@ -91,9 +98,9 @@ public class MultiLineLabel extends JPanel { } /** - * breaks specified label into array of lines. + * Breaks specified label into array of lines. * - *@param label String to display in canvas. + *@param label String to display. */ protected void newLabel(String label) { if (label == null) { @@ -119,9 +126,6 @@ public class MultiLineLabel extends JPanel { protected void measure() { FontMetrics fm = this.getFontMetrics(this.getFont()); - - // if no font metrics yet, just return - if (fm == null) { return; } @@ -139,9 +143,9 @@ public class MultiLineLabel extends JPanel { } /** - * Set a new label for JPanel + * Set a new label to display. * - * @param label String to display in canvas + * @param label String to display */ public void setLabel(String label) { @@ -163,7 +167,7 @@ public class MultiLineLabel extends JPanel { } /** - * Get the label text. + * {@return the label text.} */ public String getLabel() { StringBuffer sb = new StringBuffer(); @@ -189,11 +193,6 @@ public class MultiLineLabel extends JPanel { repaint(); } - /** - * Sets a new color for Canvas - * - *@param c Color to display in canvas - */ @Override public void setForeground(Color c) { super.setForeground(c); @@ -209,6 +208,15 @@ public class MultiLineLabel extends JPanel { repaint(); } + /** + * Sets the vertical alignment of the text. The default is {@link VerticalAlignment#MIDDLE}. + * @param alignment the alignment + */ + public void setVerticalAlignment(VerticalAlignment alignment) { + this.verticalAlignment = alignment; + repaint(); + } + /** * Set margin width. * @param mw the new margin width. @@ -227,29 +235,20 @@ public class MultiLineLabel extends JPanel { repaint(); } - /** - * Get alignment for text, LEFT, CENTER, RIGHT. - */ public final int getAlignment() { return alignment; } - /** - * Get margin width. - */ public final int getMarginWidth() { return margin_width; } - /** - *Get margin height. - */ public final int getMarginHeight() { return margin_height; } /** - * This method is invoked after Canvas is first created + * This method is invoked after this class is first created * but before it can be actually displayed. After we have * invoked our superclass's addNotify() method, we have font * metrics and can successfully call measure() to figure out @@ -257,48 +256,38 @@ public class MultiLineLabel extends JPanel { */ @Override public void addNotify() { - super.addNotify(); measure(); } - /** - * This method is called by a layout manager when it wants - * to know how big we'd like to be - */ @Override - public java.awt.Dimension getPreferredSize() { + public Dimension getPreferredSize() { return new Dimension(max_width + 2 * margin_width, num_lines * line_height + 2 * margin_height); } - /** - * This method is called when layout manager wants to - * know the bare minimum amount of space we need to get by. - */ @Override - public java.awt.Dimension getMinimumSize() { + public Dimension getMinimumSize() { return new Dimension(max_width, num_lines * line_height); } - /** - * This method draws label (applets use same method). - * Note that it handles the margins and the alignment, but - * that is does not have to worry about the color or font -- - * the superclass takes care of setting those in the Graphics - * object we've passed. - * @param g the graphics context to paint with. - */ @Override public void paint(Graphics g) { - int x, y; - Dimension d = this.getSize(); -// g.clearRect(0, 0, d.width, d.height); + paintBorder(g); - y = line_ascent + (d.height - num_lines * line_height) / 2; + Dimension d = this.getSize(); + + int y; + if (verticalAlignment == VerticalAlignment.MIDDLE) { + y = line_ascent + (d.height - num_lines * line_height) / 2; + } + else { + y = margin_height + line_ascent; + } + int x; for (int i = 0; i < num_lines; i++, y += line_height) { switch (alignment) { case LEFT: @@ -313,14 +302,11 @@ public class MultiLineLabel extends JPanel { break; } + GraphicsUtils.drawString(this, g, lines[i], x, y); } } - /** - * Simple test for the MultiLineLabel class. - * @param args not used - */ public static void main(String[] args) { MultiLineLabel mlab = new MultiLineLabel( diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/filter/ClearFilterLabel.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/filter/ClearFilterLabel.java index b18f9f92b5..bc1d7894d2 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/filter/ClearFilterLabel.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/filter/ClearFilterLabel.java @@ -29,9 +29,8 @@ import org.jdesktop.animation.timing.interpolation.PropertySetter; import docking.util.AnimationUtils; import docking.widgets.label.GIconLabel; +import generic.theme.GIcon; import ghidra.util.SystemUtilities; -import resources.Icons; -import resources.ResourceManager; /** * A label that displays an icon that, when clicked, will clear the contents of the @@ -39,8 +38,7 @@ import resources.ResourceManager; */ public class ClearFilterLabel extends GIconLabel { - private Icon RAW_ICON = Icons.DELETE_ICON; - private Icon ICON = ResourceManager.getScaledIcon(RAW_ICON, 10, 10); + private Icon ICON = new GIcon("icon.text.field.clear"); private static final float FULLY_TRANSPARENT = 0F; private static final float FULLY_OPAQUE = .6F; diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/dialog/KeyBindingsPanel.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/dialog/KeyBindingsPanel.java index 3b4efc558b..0a78fe40c6 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/dialog/KeyBindingsPanel.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/dialog/KeyBindingsPanel.java @@ -33,14 +33,14 @@ import docking.action.DockingActionIf; import docking.actions.*; import docking.tool.util.DockingToolConstants; import docking.widgets.*; -import docking.widgets.label.GIconLabel; +import docking.widgets.MultiLineLabel.VerticalAlignment; import docking.widgets.table.*; import generic.theme.Gui; import ghidra.framework.options.*; import ghidra.framework.plugintool.PluginTool; -import ghidra.util.*; -import ghidra.util.layout.PairLayout; -import ghidra.util.layout.VerticalLayout; +import ghidra.framework.plugintool.ServiceProviderStub; +import ghidra.util.Msg; +import ghidra.util.Swing; import gui.event.MouseBinding; import help.Help; import help.HelpService; @@ -51,7 +51,8 @@ import resources.Icons; */ public class KeyBindingsPanel extends JPanel { - private static final int STATUS_LABEL_HEIGHT = 60; + private static final String GETTING_STARTED_MESSAGE = + "Select an action to change a keybinding"; private final static int ACTION_NAME = 0; private final static int KEY_BINDING = 1; @@ -61,7 +62,6 @@ public class KeyBindingsPanel extends JPanel { private JTextPane statusLabel; private GTable actionTable; - private JPanel infoPanel; private MultiLineLabel collisionLabel; private KeyBindingsTableModel tableModel; private ActionBindingListener actionBindingListener = new ActionBindingListener(); @@ -76,6 +76,9 @@ public class KeyBindingsPanel extends JPanel { private boolean firingTableDataChanged; private PropertyChangeListener propertyChangeListener; + private JPanel gettingStartedPanel; + private JPanel activeActionPanel; + public KeyBindingsPanel(PluginTool tool) { this.tool = tool; @@ -110,7 +113,7 @@ public class KeyBindingsPanel extends JPanel { // clear the action to avoid the appearance of editing while restoring actionTable.clearSelection(); - restoreDefaultKeybindings(); + restoreDefaultKeyBindings(); }); } @@ -135,50 +138,69 @@ public class KeyBindingsPanel extends JPanel { } private void createPanelComponents() { + setLayout(new BorderLayout(10, 10)); + // A stub panel to take up about the same amount of space as the active panel. This stub + // panel will get swapped for the active panel when a selection is made in the table. Using + // the stub panel is easier than trying to visually disable the editing widgets. + gettingStartedPanel = new JPanel(); + activeActionPanel = createActiveActionPanel(); + tableModel = new KeyBindingsTableModel(new ArrayList<>(keyBindings.getUniqueActions())); actionTable = new GTable(tableModel); - JScrollPane sp = new JScrollPane(actionTable); + JScrollPane actionsScroller = new JScrollPane(actionTable); actionTable.setPreferredScrollableViewportSize(new Dimension(400, 100)); actionTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); actionTable.setHTMLRenderingEnabled(true); + actionTable.getSelectionModel().addListSelectionListener(new TableSelectionListener()); adjustTableColumns(); // middle panel - filter field and import/export buttons JPanel importExportPanel = createImportExportPanel(); tableFilterPanel = new GTableFilterPanel<>(actionTable, tableModel); - JPanel middlePanel = new JPanel(new BorderLayout()); - middlePanel.add(tableFilterPanel, BorderLayout.NORTH); - middlePanel.add(importExportPanel, BorderLayout.SOUTH); + JPanel filterAndExportsPanel = new JPanel(new BorderLayout()); + filterAndExportsPanel.add(tableFilterPanel, BorderLayout.NORTH); + filterAndExportsPanel.add(importExportPanel, BorderLayout.SOUTH); - // contains the upper panel (table) and the middle panel) + // contains the upper panel (table and the middle panel) JPanel centerPanel = new JPanel(new BorderLayout()); - centerPanel.add(sp, BorderLayout.CENTER); - centerPanel.add(middlePanel, BorderLayout.SOUTH); + centerPanel.add(actionsScroller, BorderLayout.CENTER); + centerPanel.add(filterAndExportsPanel, BorderLayout.SOUTH); + + add(centerPanel, BorderLayout.CENTER); + add(gettingStartedPanel, BorderLayout.SOUTH); + + // make both panels the same size so that as we swap them, the UI doesn't jump + Dimension preferredSize = activeActionPanel.getPreferredSize(); + gettingStartedPanel.setPreferredSize(preferredSize); + } + + private JPanel createActiveActionPanel() { // lower panel - key entry panel and status panel JPanel keyPanel = createKeyEntryPanel(); - JComponent statusPanel = createStatusPanel(keyPanel); + JPanel collisionAreaPanel = createCollisionArea(); - add(centerPanel, BorderLayout.CENTER); - add(statusPanel, BorderLayout.SOUTH); - - actionTable.getSelectionModel().addListSelectionListener(new TableSelectionListener()); + JPanel parentPanel = new JPanel(new BorderLayout()); + parentPanel.add(keyPanel, BorderLayout.NORTH); + parentPanel.add(collisionAreaPanel, BorderLayout.SOUTH); + return parentPanel; } - private JPanel createStatusPanel(JPanel keyPanel) { + private JPanel createStatusPanel() { statusLabel = new JTextPane(); statusLabel.setEnabled(false); DockingUtils.setTransparent(statusLabel); statusLabel.setBorder(BorderFactory.createEmptyBorder(5, 10, 0, 5)); statusLabel.setContentType("text/html"); // render any HTML we find in descriptions + statusLabel.setText(GETTING_STARTED_MESSAGE); - // make sure the label gets enough space - statusLabel.setPreferredSize(new Dimension(0, STATUS_LABEL_HEIGHT)); + // make the label wide enough to show a line of text, but set a limit to force wrapping + statusLabel.setPreferredSize(new Dimension(300, 30)); statusLabel.setFont(Gui.getFont(FONT_ID)); helpButton = new EmptyBorderButton(Icons.HELP_ICON); @@ -189,70 +211,47 @@ public class KeyBindingsPanel extends JPanel { hs.showHelp(action, false, KeyBindingsPanel.this); }); - JPanel helpButtonPanel = new JPanel(); - helpButtonPanel.setLayout(new BoxLayout(helpButtonPanel, BoxLayout.PAGE_AXIS)); - helpButtonPanel.add(helpButton); - helpButtonPanel.add(Box.createVerticalGlue()); + JPanel statusPanel = new JPanel(); + statusPanel.setLayout(new BoxLayout(statusPanel, BoxLayout.LINE_AXIS)); + statusPanel.add(helpButton); + statusPanel.add(statusLabel); - JPanel lowerStatusPanel = new JPanel(); - lowerStatusPanel.setLayout(new BoxLayout(lowerStatusPanel, BoxLayout.X_AXIS)); - lowerStatusPanel.add(helpButtonPanel); - lowerStatusPanel.add(statusLabel); + statusPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 0, 0)); - JPanel panel = new JPanel(new VerticalLayout(5)); - panel.add(keyPanel); - panel.add(lowerStatusPanel); - return panel; + return statusPanel; } private JPanel createKeyEntryPanel() { actionBindingPanel = new ActionBindingPanel(actionBindingListener); - // this is the lower panel that holds the key entry text field - JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT)); - p.add(actionBindingPanel); + // add some space at the bottom of the input area to separate it from the info area + actionBindingPanel.setBorder(BorderFactory.createEmptyBorder(10, 0, 20, 0)); JPanel keyPanel = new JPanel(new BorderLayout()); - - JPanel defaultPanel = new JPanel(new BorderLayout()); - - // the content of the left-hand side label - MultiLineLabel mlabel = - new MultiLineLabel("To add or change a key binding, select an action\n" + - "and type any key combination."); - JPanel labelPanel = new JPanel(); - labelPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 0, 0)); - BoxLayout bl = new BoxLayout(labelPanel, BoxLayout.X_AXIS); - labelPanel.setLayout(bl); - labelPanel.add(Box.createHorizontalStrut(5)); - labelPanel.add(new GIconLabel(Icons.INFO_ICON)); - labelPanel.add(Box.createHorizontalStrut(5)); - labelPanel.add(mlabel); - - // the default panel is the panel that holds left-hand side label - defaultPanel.add(labelPanel, BorderLayout.NORTH); - defaultPanel.setBorder(BorderFactory.createLoweredBevelBorder()); - - // the info panel is the holds the right-hand label and is inside of - // a scroll pane - infoPanel = new JPanel(new FlowLayout(FlowLayout.CENTER)); - collisionLabel = new MultiLineLabel(" "); - collisionLabel.setName("CollisionLabel"); - - infoPanel.add(collisionLabel); - JScrollPane sp = new JScrollPane(infoPanel); - sp.setPreferredSize(defaultPanel.getPreferredSize()); - - // inner panel holds the two label panels - JPanel innerPanel = new JPanel(new PairLayout(2, 6)); - innerPanel.add(defaultPanel); - innerPanel.add(sp); - - keyPanel.add(innerPanel, BorderLayout.CENTER); - keyPanel.add(p, BorderLayout.SOUTH); + keyPanel.add(actionBindingPanel, BorderLayout.NORTH); return keyPanel; } + private JPanel createCollisionArea() { + + collisionLabel = new MultiLineLabel(" "); + collisionLabel.setVerticalAlignment(VerticalAlignment.TOP); + collisionLabel.setName("CollisionLabel"); + JScrollPane collisionScroller = new JScrollPane(collisionLabel); + int height = 100; // enough to show the typical number of collisions without scrolling + collisionScroller.setPreferredSize(new Dimension(400, height)); + + // note: we add a strut so that when the scroll pane is hidden, the size does not change + JPanel parentPanel = new JPanel(new BorderLayout()); + parentPanel.add(collisionScroller, BorderLayout.CENTER); + parentPanel.add(Box.createVerticalStrut(height), BorderLayout.WEST); + + JPanel alignmentPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + alignmentPanel.add(parentPanel); + + return alignmentPanel; + } + private JPanel createImportExportPanel() { JButton importButton = new JButton("Import..."); importButton.setToolTipText("Load key binding settings from a file"); @@ -290,11 +289,16 @@ public class KeyBindingsPanel extends JPanel { }); }); - JPanel containerPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); - containerPanel.add(importButton); - containerPanel.add(exportButton); + JPanel statusPanel = createStatusPanel(); - return containerPanel; + JPanel buttonPanel = new JPanel(); + buttonPanel.add(importButton); + buttonPanel.add(exportButton); + + JPanel parentPanel = new JPanel(new BorderLayout()); + parentPanel.add(statusPanel, BorderLayout.WEST); + parentPanel.add(buttonPanel, BorderLayout.EAST); + return parentPanel; } private boolean showApplyPrompt() { @@ -354,7 +358,7 @@ public class KeyBindingsPanel extends JPanel { column.setPreferredWidth(150); } - private void restoreDefaultKeybindings() { + private void restoreDefaultKeyBindings() { keyBindings.restoreOptions(); // let the table know that changes may have been made @@ -398,13 +402,20 @@ public class KeyBindingsPanel extends JPanel { } private void updateCollisionPanel(String text) { - infoPanel.removeAll(); - infoPanel.repaint(); - collisionLabel = new MultiLineLabel(text); - collisionLabel.setName("CollisionLabel"); - infoPanel.add(collisionLabel); - infoPanel.invalidate(); + + // Hide the scroll pane when there is nothing to show + Container parent = collisionLabel.getParent().getParent(); + if (text.isBlank()) { + parent.setVisible(false); + } + else { + parent.setVisible(true); + } + + collisionLabel.setLabel(text); + collisionLabel.invalidate(); validate(); + repaint(); } private void loadKeyBindingsFromImportedOptions(Options keyBindingOptions) { @@ -448,7 +459,7 @@ public class KeyBindingsPanel extends JPanel { DockingActionIf action = getSelectedAction(); if (action == null) { - statusLabel.setText("No action is selected."); + statusLabel.setText(GETTING_STARTED_MESSAGE); return; } @@ -463,7 +474,7 @@ public class KeyBindingsPanel extends JPanel { String selectedActionName = action.getFullName(); if (setActionKeyStroke(selectedActionName, ks)) { showActionsMappedToKeyStroke(ks); - tableModel.fireTableDataChanged(); + fireRowChanged(); changesMade(true); } } @@ -474,17 +485,23 @@ public class KeyBindingsPanel extends JPanel { DockingActionIf action = getSelectedAction(); if (action == null) { - statusLabel.setText("No action is selected."); + statusLabel.setText(GETTING_STARTED_MESSAGE); return; } String selectedActionName = action.getFullName(); if (setMouseBinding(selectedActionName, mb)) { - tableModel.fireTableDataChanged(); + fireRowChanged(); changesMade(true); } } + private void fireRowChanged() { + int viewRow = actionTable.getSelectedRow(); + int modelRow = tableFilterPanel.getModelRow(viewRow); + tableModel.fireTableRowsUpdated(modelRow, modelRow); + } + private boolean setMouseBinding(String actionName, MouseBinding mouseBinding) { if (keyBindings.isMouseBindingInUse(actionName, mouseBinding)) { @@ -525,9 +542,25 @@ public class KeyBindingsPanel extends JPanel { return keyBindings.getKeyStrokesByFullActionName(); } + private void swapView(JComponent newView) { + + // the lower panel we want to swap is at index 1 (index 0 is the table area) + Component component = getComponent(1); + if (component == newView) { + return; // nothing to do + } + + remove(component); + add(newView, BorderLayout.SOUTH); + Container parent = getParent(); + parent.validate(); + parent.repaint(); + } + //================================================================================================== // Inner Classes //================================================================================================== + /** * Selection listener class for the table model. */ @@ -539,15 +572,22 @@ public class KeyBindingsPanel extends JPanel { } helpButton.setEnabled(false); - String fullActionName = getSelectedActionName(); - if (fullActionName == null) { - statusLabel.setText(""); + + DockingActionIf action = getSelectedAction(); + if (action == null) { + swapView(gettingStartedPanel); + + statusLabel.setText(GETTING_STARTED_MESSAGE); actionBindingPanel.setEnabled(false); + helpButton.setToolTipText("Select action in table for help"); return; } - actionBindingPanel.setEnabled(true); + String fullActionName = getSelectedActionName(); + swapView(activeActionPanel); + + actionBindingPanel.setEnabled(true); helpButton.setEnabled(true); clearInfoPanel(); @@ -559,59 +599,54 @@ public class KeyBindingsPanel extends JPanel { MouseBinding mb = keyBindings.getMouseBinding(fullActionName); actionBindingPanel.setKeyBindingData(ks, mb); - // make sure the label gets enough space - statusLabel.setPreferredSize( - new Dimension(statusLabel.getPreferredSize().width, STATUS_LABEL_HEIGHT)); - - DockingActionIf action = getSelectedAction(); String description = action.getDescription(); - if (description == null || description.trim().isEmpty()) { + if (StringUtils.isBlank(description)) { description = action.getName(); } - statusLabel.setText("" + HTMLUtilities.escapeHTML(description)); + // Not sure why we escape the html here. Probably just to be safe. + statusLabel.setText("" + description); + helpButton.setToolTipText("Help for " + action.getName()); } } - private class KeyBindingsTableModel extends AbstractSortedTableModel { - private final String[] columnNames = { "Action Name", "KeyBinding", "Plugin Name" }; + private class KeyBindingsTableModel + extends GDynamicColumnTableModel { private List actions; - KeyBindingsTableModel(List actions) { - super(0); + public KeyBindingsTableModel(List actions) { + super(new ServiceProviderStub()); this.actions = actions; } @Override - public String getName() { - return "Keybindings"; + protected TableColumnDescriptor createTableColumnDescriptor() { + TableColumnDescriptor descriptor = new TableColumnDescriptor<>(); + descriptor.addVisibleColumn("Action Name", String.class, a -> a.getName(), 1, true); + descriptor.addVisibleColumn("Key Binding", String.class, a -> { + String text = ""; + String fullName = a.getFullName(); + KeyStroke ks = keyBindings.getKeyStroke(fullName); + if (ks != null) { + text += KeyBindingUtils.parseKeyStroke(ks); + } + + MouseBinding mb = keyBindings.getMouseBinding(fullName); + if (mb != null) { + text += " (" + mb.getDisplayText() + ")"; + } + + return text.trim(); + }); + descriptor.addVisibleColumn("Owner", String.class, a -> a.getOwnerDescription()); + descriptor.addHiddenColumn("Description", String.class, a -> a.getDescription()); + return descriptor; } @Override - public Object getColumnValueForRow(DockingActionIf action, int columnIndex) { - - String fullName = action.getFullName(); - switch (columnIndex) { - case ACTION_NAME: - return action.getName(); - case KEY_BINDING: - String text = ""; - KeyStroke ks = keyBindings.getKeyStroke(fullName); - if (ks != null) { - text += KeyBindingUtils.parseKeyStroke(ks); - } - - MouseBinding mb = keyBindings.getMouseBinding(fullName); - if (mb != null) { - text += " (" + mb.getDisplayText() + ")"; - } - - return text.trim(); - case PLUGIN_NAME: - return action.getOwnerDescription(); - } - return "Unknown Column!"; + public String getName() { + return "Key Bindings"; } @Override @@ -620,28 +655,8 @@ public class KeyBindingsPanel extends JPanel { } @Override - public boolean isSortable(int columnIndex) { - return true; - } - - @Override - public String getColumnName(int column) { - return columnNames[column]; - } - - @Override - public int getColumnCount() { - return columnNames.length; - } - - @Override - public int getRowCount() { - return actions.size(); - } - - @Override - public Class getColumnClass(int columnIndex) { - return String.class; + public Object getDataSource() { + return null; } }