diff --git a/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/gui/AbstractGhidraHeadedDebuggerTest.java b/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/gui/AbstractGhidraHeadedDebuggerTest.java index 8e662d2a29..1644727887 100644 --- a/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/gui/AbstractGhidraHeadedDebuggerTest.java +++ b/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/gui/AbstractGhidraHeadedDebuggerTest.java @@ -402,7 +402,7 @@ public abstract class AbstractGhidraHeadedDebuggerTest /** * Only use this to escape from pop-up menus. Otherwise, use - * {@link #triggerEscapeKey(Component)}. + * {@link #triggerEscape(Component)}. * * @throws AWTException */ diff --git a/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/gui/model/DebuggerModelProviderTest.java b/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/gui/model/DebuggerModelProviderTest.java index 7edafafa1d..61be7fdc82 100644 --- a/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/gui/model/DebuggerModelProviderTest.java +++ b/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/gui/model/DebuggerModelProviderTest.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. @@ -401,7 +401,7 @@ public class DebuggerModelProviderTest extends AbstractGhidraHeadedDebuggerTest waitForTasks(); modelProvider.pathField.setText("SomeNonsenseToBeCancelled"); - triggerEscapeKey(modelProvider.pathField); + triggerEscape(modelProvider.pathField); waitForSwing(); assertPathIsThreadsContainer(); diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/app/merge/listing/AbstractListingMergeManagerTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/app/merge/listing/AbstractListingMergeManagerTest.java index 8e5cb332f1..cf8a14ba2f 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/app/merge/listing/AbstractListingMergeManagerTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/app/merge/listing/AbstractListingMergeManagerTest.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. @@ -195,14 +195,14 @@ public abstract class AbstractListingMergeManagerTest extends AbstractMergeTest KeyboardFocusManager.getCurrentKeyboardFocusManager().getActiveWindow(); assertNotNull(activeWindow); waitForSwing(); - triggerEscapeKey(activeWindow); + triggerEscape(activeWindow); } void escapeWindowWithTitleContaining(String partOfTitle) { Window win = getWindowWithTitleContaining(partOfTitle); if (win != null) { waitForSwing(); - triggerEscapeKey(win); + triggerEscape(win); } } diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/main/datatree/FrontEndPluginActionsTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/main/datatree/FrontEndPluginActionsTest.java index 5b32a8c9e5..b672a9418d 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/main/datatree/FrontEndPluginActionsTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/main/datatree/FrontEndPluginActionsTest.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. @@ -772,7 +772,7 @@ public class FrontEndPluginActionsTest extends AbstractGhidraHeadedIntegrationTe private void clearText(Component c, String text) { int n = text.length(); for (int i = 0; i < n; i++) { - triggerBackspaceKey(c); + triggerBackspace(c); } } 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 e36d02be5d..5851a9238f 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/actions/KeyBindingUtils.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/actions/KeyBindingUtils.java @@ -56,6 +56,8 @@ import utilities.util.reflection.ReflectionUtilities; * @since Tracker Id 329 */ public class KeyBindingUtils { + private static final String NO_KEYBINDING_NAME = "none"; + private static final String LAST_KEY_BINDING_EXPORT_DIRECTORY = "LastKeyBindingExportDirectory"; private static final String RELEASED = "released"; @@ -338,7 +340,7 @@ public class KeyBindingUtils { } Object keyText = im.get(keyStroke); - if (keyText == null) { + if (keyText == null || keyText.equals(NO_KEYBINDING_NAME)) { // no binding--just pick a name keyText = action.getValue(Action.NAME); if (keyText == null) { @@ -404,7 +406,7 @@ public class KeyBindingUtils { int focusCondition) { InputMap inputMap = component.getInputMap(focusCondition); if (inputMap != null) { - inputMap.put(keyStroke, "none"); + inputMap.put(keyStroke, NO_KEYBINDING_NAME); } } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/test/AbstractDockingTest.java b/Ghidra/Framework/Docking/src/main/java/docking/test/AbstractDockingTest.java index de514e9ba4..9aff2982cd 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/test/AbstractDockingTest.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/test/AbstractDockingTest.java @@ -1503,7 +1503,7 @@ public abstract class AbstractDockingTest extends AbstractGuiTest { triggerKey(destination, modifiers, keyCode, keyChar); } - public static void triggerEscapeKey(Component c) { + public static void triggerEscape(Component c) { // text components will not perform built-in actions if they are not focused if (c instanceof JTextComponent) { triggerFocusGained(c); @@ -1511,7 +1511,7 @@ public abstract class AbstractDockingTest extends AbstractGuiTest { triggerText(c, "\033"); } - public static void triggerBackspaceKey(Component c) { + public static void triggerBackspace(Component c) { triggerText(c, "\010"); } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/filechooser/GhidraFileChooser.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/filechooser/GhidraFileChooser.java index d99fd98a73..39bc07c987 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/filechooser/GhidraFileChooser.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/filechooser/GhidraFileChooser.java @@ -29,15 +29,19 @@ import javax.swing.*; import javax.swing.event.CellEditorListener; import javax.swing.event.ChangeEvent; import javax.swing.filechooser.FileSystemView; +import javax.swing.text.DefaultFormatter; +import javax.swing.text.DefaultFormatterFactory; import org.apache.commons.lang3.StringUtils; import docking.*; +import docking.actions.KeyBindingUtils; import docking.widgets.*; import docking.widgets.combobox.GComboBox; import docking.widgets.label.GDLabel; import docking.widgets.label.GLabel; import docking.widgets.list.GListCellRenderer; +import docking.widgets.textfield.GFormattedTextField; import generic.theme.GColor; import generic.theme.GIcon; import ghidra.framework.preferences.Preferences; @@ -171,7 +175,7 @@ public class GhidraFileChooser extends ReusableDialogComponentProvider implement private FileChooserToggleButton downloadsButton; private FileChooserToggleButton recentButton; - private JTextField currentPathTextField; + private GFormattedTextField currentPathTextField; private DropDownSelectionTextField filenameTextField; private DirectoryTableModel directoryTableModel; private DirectoryTable directoryTable; @@ -378,13 +382,13 @@ public class GhidraFileChooser extends ReusableDialogComponentProvider implement @Override public void editingStopped(ChangeEvent e) { - // the user has cancelled editing in the text field (i.e., they pressed ESCAPE) + // the user has cancelled editing in the text field (i.e., they pressed ENTER) enterCallback(); } @Override public void editingCanceled(ChangeEvent e) { - // the user has committed editing from the text field (i.e, they pressed ENTER) + // the user has committed editing from the text field (i.e, they pressed ESCAPE) escapeCallback(); } }); @@ -460,14 +464,106 @@ public class GhidraFileChooser extends ReusableDialogComponentProvider implement gbc.gridx = afterPathLabel; gbc.fill = GridBagConstraints.HORIZONTAL; gbc.weightx = 1.0; - currentPathTextField = new JTextField(); - currentPathTextField.setName("Path"); - currentPathTextField.setEditable(false); + currentPathTextField = buildPathTextField(); headerPanel.add(currentPathTextField, gbc); return headerPanel; } + private GFormattedTextField buildPathTextField() { + DefaultFormatter formatter = new DefaultFormatter(); + formatter.setOverwriteMode(false); + DefaultFormatterFactory factory = new DefaultFormatterFactory(formatter); + GFormattedTextField textField = new GFormattedTextField(factory, "") { + @Override + public void setText(String t) { + super.setText(t); + setDefaultValue(t); + } + }; + textField.setName("Path"); + + textField.addFocusListener(new FocusAdapter() { + @Override + public void focusGained(FocusEvent e) { + lastInputFocus = textField; + } + }); + + DockingUtils.installUndoRedo(textField); + + // have the Escape key clear any edits to the field + KeyStroke escapeKs = KeyBindingUtils.parseKeyStroke("Escape"); + Action escapeAction = new AbstractAction("Reset Path") { + @Override + public void actionPerformed(ActionEvent e) { + + if (textField.isChanged()) { + textField.reset(); + } + else { + // When not edited, pass the event up to the chooser so the behavior works as + // it does elsewhere in the dialog. + escapeCallback(); + } + } + }; + + // remove the table's escape key binding and then add our own + KeyBindingUtils.clearKeyBinding(textField, escapeKs); + KeyBindingUtils.registerAction(textField, escapeKs, escapeAction, + JComponent.WHEN_FOCUSED); + + // update Enter to allow the user to pick the selected language + KeyStroke enterKs = KeyBindingUtils.parseKeyStroke("Enter"); + Action enterAction = new AbstractAction("Choose File") { + @Override + public void actionPerformed(ActionEvent e) { + + if (!textField.isChanged()) { + // When not edited, pass the event up to the chooser so the behavior works as + // it does elsewhere in the dialog. + enterCallback(); + return; + } + + if (!textField.isValid()) { + return; + } + + String text = textField.getText(); + File f = new File(text); + if (f.isFile()) { + setSelectedFile(f); + } + else { + updateDirOnly(f, true); + } + } + }; + + // remove the table's enter key binding and then add our own + KeyBindingUtils.clearKeyBinding(textField, enterKs); + KeyBindingUtils.registerAction(textField, enterKs, enterAction, + JComponent.WHEN_FOCUSED); + + // an input verifier that returns true if the path is an existing file or directory + InputVerifier inputVerifier = new InputVerifier() { + @Override + public boolean verify(JComponent input) { + String text = textField.getText(); + File f = new File(text); + if (isSpecialDirectory(f)) { + return true; + } + return f.isFile() || f.isDirectory(); + } + }; + textField.setInputVerifier(inputVerifier); + + return textField; + } + private void buildWaitPanel() { waitPanel = new JPanel(new BorderLayout()); waitPanel.setBorder(BorderFactory.createLoweredBevelBorder()); @@ -753,7 +849,8 @@ public class GhidraFileChooser extends ReusableDialogComponentProvider implement } private File currentDirectory() { - String path = currentPathTextField.getText(); + // The default text should always be valid, regardless of user edits + String path = currentPathTextField.getDefaultText(); if (path.length() == 0) { return null; } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/GFormattedTextField.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/GFormattedTextField.java index e76b1cfaa1..2f659d3a57 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/GFormattedTextField.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/GFormattedTextField.java @@ -97,6 +97,15 @@ public class GFormattedTextField extends JFormattedTextField { update(); } + /** + * Returns the default text. This is useful to know what the original text is after the user + * has edited the text. + * @return the default text + */ + public String getDefaultText() { + return defaultText; + } + public void disableFocusEventProcessing() { ignoreFocusEditChanges = true; } @@ -165,6 +174,30 @@ public class GFormattedTextField extends JFormattedTextField { update(); } + /** + * Restores this field to its default text. + */ + public void reset() { + setText(defaultText); + update(); + } + + /** + * Returns true if the contents of this field do not match the default. + * @return true if the contents of this field do not match the default. + */ + public boolean isChanged() { + return getTextEntryStatus() != Status.UNCHANGED; + } + + /** + * Returns true if the contents of this field are invalid, as determined by the InputValidator. + * @return true if the contents of this field are invalid, as determined by the InputValidator. + */ + public boolean isInvalid() { + return getTextEntryStatus() == Status.INVALID; + } + public void editingFinished() { update(); } @@ -196,7 +229,6 @@ public class GFormattedTextField extends JFormattedTextField { } private void update() { - updateStatus(); if (isError) { setForeground(Colors.FOREGROUND); @@ -231,5 +263,4 @@ public class GFormattedTextField extends JFormattedTextField { textEntryStatusChanged(currentStatus); } } - } diff --git a/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/filechooser/GhidraFileChooserTest.java b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/filechooser/GhidraFileChooserTest.java index f14a5997d0..fa03d0a881 100644 --- a/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/filechooser/GhidraFileChooserTest.java +++ b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/filechooser/GhidraFileChooserTest.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. @@ -50,6 +50,8 @@ import docking.test.AbstractDockingTest; import docking.widgets.DropDownSelectionTextField; import docking.widgets.SpyDropDownWindowVisibilityListener; import docking.widgets.table.*; +import docking.widgets.textfield.GFormattedTextField; +import docking.widgets.textfield.GFormattedTextField.Status; import generic.concurrent.ConcurrentQ; import ghidra.framework.OperatingSystem; import ghidra.framework.Platform; @@ -367,7 +369,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest { // a string long enough to make the pick unique, in case there are similarly named files String filenameText = prefix.substring(0, 13); - typeTextForTextField(filenameText); + typeTextForFilenameTextField(filenameText); triggerEnter(getFilenameTextField()); // the following code should be put back if we move to the Enter key press simply closing the @@ -802,7 +804,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest { setFilenameFieldText("");// clear any text so that we can trigger the matching window String desktopText = "test"; - typeTextForTextField(desktopText); + typeTextForFilenameTextField(desktopText); assertDropDownWindowIsShowing(true); @@ -1976,6 +1978,70 @@ public class GhidraFileChooserTest extends AbstractDockingTest { assertChooserPreference(key, null); } + @Test + public void testEditPath_Directory() throws Exception { + + TestFiles files = createMixedDirectory(); + File dir = files.randomDir(); + setPathFieldText(dir.getAbsolutePath()); + + triggerEnter(getPathTextField()); + waitForChooser(); + + File currentDir = getCurrentDirectory(); + assertEquals(dir, currentDir); + } + + @Test + public void testEditPath_File() throws Exception { + TestFiles files = createMixedDirectory(); + File file = files.randomFile(); + setPathFieldText(file.getAbsolutePath()); + + triggerEnter(getPathTextField()); + waitForChooser(); + + File currentDir = getCurrentDirectory(); + File parent = file.getParentFile(); + assertEquals(parent, currentDir); + + File selectedFile = getSelectedFile(); + assertEquals(file, selectedFile); + } + + @Test + public void testEditPath_InvalidPath_Enter() throws Exception { + + File startDir = getCurrentDirectory(); + + setPathFieldText("/some/fake/path"); + triggerEnter(getPathTextField()); + waitForChooser(); + + assertPathFieldIsInvalid(); + + // no change + File currentDir = getCurrentDirectory(); + assertEquals(startDir, currentDir); + } + + @Test + public void testEditPath_InvalidPath_Escape() throws Exception { + + File startDir = getCurrentDirectory(); + + setPathFieldText("/some/fake/path"); + assertPathFieldIsInvalid(); + + triggerEscape(getPathTextField()); + waitForChooser(); + assertPathFieldIsUnchanged(); + + // no change + File currentDir = getCurrentDirectory(); + assertEquals(startDir, currentDir); + } + //================================================================================================== // Private Methods //================================================================================================== @@ -2348,7 +2414,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest { } } - private void typeTextForTextField(String text) { + private void typeTextForFilenameTextField(String text) { JTextField textField = getFilenameTextField(); triggerText(textField, text); waitForSwing(); @@ -2375,11 +2441,20 @@ public class GhidraFileChooserTest extends AbstractDockingTest { } } - private void setFilenameFieldText(final String text) { - final JTextField textField = getFilenameTextField(); + private void setFilenameFieldText(String text) { + JTextField textField = getFilenameTextField(); + runSwing(() -> textField.requestFocusInWindow()); + runSwing(() -> textField.setText(text)); + } + + private void setPathFieldText(String text) { + GFormattedTextField textField = getPathTextField(); runSwing(() -> textField.requestFocusInWindow()); - runSwing(() -> textField.setText(text)); + // note: we cannot call textField.setText() here, as that is overridden to make the new + // text the default value, which affects our test for textField.isChanged(). + triggerText(textField, text); + waitForSwing(); } private String getFilenameFieldText() { @@ -2393,6 +2468,10 @@ public class GhidraFileChooserTest extends AbstractDockingTest { return textField; } + private GFormattedTextField getPathTextField() { + return (GFormattedTextField) findComponentByName(chooser, "Path"); + } + private void show() throws Exception { show(true); } @@ -2743,6 +2822,18 @@ public class GhidraFileChooserTest extends AbstractDockingTest { fail("File chooser does not in its list have file: " + expected); } + private void assertPathFieldIsInvalid() { + GFormattedTextField textField = getPathTextField(); + Status status = runSwing(() -> textField.getTextEntryStatus()); + assertEquals(Status.INVALID, status); + } + + private void assertPathFieldIsUnchanged() { + GFormattedTextField textField = getPathTextField(); + Status status = runSwing(() -> textField.getTextEntryStatus()); + assertEquals(Status.UNCHANGED, status); + } + private void assertChooserHidden() { assertFalse("The chooser is showing; it should be closed", runSwing(() -> chooser.isShowing())); diff --git a/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/textfield/IntegerTextFieldTest.java b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/textfield/IntegerTextFieldTest.java index 138e229656..512c32f27c 100644 --- a/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/textfield/IntegerTextFieldTest.java +++ b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/textfield/IntegerTextFieldTest.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. @@ -99,7 +99,7 @@ public class IntegerTextFieldTest extends AbstractDockingTest { assertTrue(!field.isHexMode()); triggerText(textField, "x"); assertTrue(field.isHexMode()); - triggerBackspaceKey(textField); + triggerBackspace(textField); assertTrue(!field.isHexMode()); } @@ -200,7 +200,7 @@ public class IntegerTextFieldTest extends AbstractDockingTest { assertEquals(12, listener.values.get(1)); assertEquals(123, listener.values.get(2)); - triggerBackspaceKey(textField); + triggerBackspace(textField); assertEquals(12, listener.values.get(3)); }