GT-3396 - File Chooser - review fixes

This commit is contained in:
dragonmacher 2019-12-16 14:46:41 -05:00
parent 171914f49e
commit 6918ef7b45
13 changed files with 797 additions and 466 deletions

View file

@ -15,11 +15,12 @@
*/
package ghidra.app.util.html;
import ghidra.app.util.html.diff.DataTypeDiffInput;
import ghidra.util.StringUtilities;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import ghidra.app.util.html.diff.DataTypeDiffInput;
public class HTMLDataTypeRepresentationDiffInput implements DataTypeDiffInput {
private HTMLDataTypeRepresentation source;
@ -43,6 +44,6 @@ public class HTMLDataTypeRepresentationDiffInput implements DataTypeDiffInput {
@Override
public String toString() {
return source.getClass().getSimpleName() + '\n' + StringUtilities.toString(lines, ",\n");
return source.getClass().getSimpleName() + '\n' + StringUtils.join(lines, ",\n");
}
}

View file

@ -0,0 +1,316 @@
/* ###
* IP: GHIDRA
*
* 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.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package docking.widgets;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import org.apache.commons.lang3.StringUtils;
/**
* A class that holds the logic and state for finding matching rows in a widget when a user types
* in the widget. This class was designed for row-based widgets, such as tables and lists.
*/
public abstract class AutoLookup {
public static final long KEY_TYPING_TIMEOUT = 800;
private static final int MAX_SEARCH_ROWS = 50000;
private long keyTimeout = KEY_TYPING_TIMEOUT;
private AutoLookupItem lastLookup;
private int lookupColumn = 0;
/**
* Returns the currently selected row
* @return the row
*/
public abstract int getCurrentRow();
/**
* Returns the total number of rows
* @return the row count
*/
public abstract int getRowCount();
/**
* Returns a string representation of the item at the given row and column. The text
* should match what the user sees.
*
* @param row the row
* @param col the column
* @return the text
*/
public abstract String getValueString(int row, int col);
/**
* Returns true if the given column is sorted. This class will use a binary search if the
* given column is sorted. Otherwise, a brute-force search will be used.
*
* @param column the column
* @return true if sorted
*/
public abstract boolean isSorted(int column);
/**
* Returns true if the currently sorted column is sorted ascending. This is used in
* conjunction with {@link #isSorted(int)}. If that method returns false, then this method
* will not be called.
*
* @return true if sorted ascending
*/
public abstract boolean isSortedAscending();
/**
* This method will be called when a match for the call to {@link #keyTyped(KeyEvent)} is
* found
*
* @param row the matching row
*/
public abstract void matchFound(int row);
/**
* Sets the delay between keystrokes after which each keystroke is considered a new lookup
* @param timeout the timeout
*/
public void setTimeout(long timeout) {
keyTimeout = timeout;
lastLookup = null;
}
/**
* Sets the column that is searched when a lookup is performed
* @param column the column
*/
public void setColumn(int column) {
this.lookupColumn = column;
}
/**
* Clients call this method when the user types keys
*
* @param e the key event
*/
public void keyTyped(KeyEvent e) {
if (getRowCount() == 0) {
return;
}
AutoLookupItem lookup = lastLookup;
if (lookup == null) {
lookup = new AutoLookupItem();
}
lookup.keyTyped(e);
if (lookup.shouldSkip()) {
return;
}
int row = lookupText(lookup.getText());
lookup.setFoundMatch(row >= 0);
if (row >= 0) {
matchFound(row);
}
lastLookup = lookup;
}
private int lookupText(String text) {
if (text == null) {
return -1;
}
int row = getCurrentRow();
if (row >= 0 && row < getRowCount() - 1) {
if (text.length() == 1) {
// fresh search; ignore the current row, could be from a previous match
++row;
}
int col = lookupColumn;
if (textMatches(text, row, col)) {
return row;
}
}
if (isSorted(lookupColumn)) {
return autoLookupBinary(text);
}
return autoLookupLinear(text);
}
private boolean textMatches(String text, int row, int col) {
String value = getValueString(row, col);
return StringUtils.startsWithIgnoreCase(value, text);
}
private boolean isIgnorableKeyEvent(KeyEvent event) {
// ignore modified keys, except for SHIFT
if (!isUnmodifiedOrShift(event.getModifiersEx())) {
return true;
}
if (event.isActionKey() || event.getKeyChar() == KeyEvent.CHAR_UNDEFINED ||
Character.isISOControl(event.getKeyChar())) {
return true;
}
return false;
}
private boolean isUnmodifiedOrShift(int modifiers) {
if (modifiers == 0) {
return true;
}
int shift = InputEvent.SHIFT_DOWN_MASK;
return (modifiers | shift) != shift;
}
private int autoLookupLinear(String text) {
int max = MAX_SEARCH_ROWS;
int rows = getRowCount();
int start = getCurrentRow();
int counter = 0;
int col = lookupColumn;
// first search from the current row until the last row
for (int i = start + 1; i < rows && counter < max; i++, counter++) {
if (textMatches(text, i, col)) {
return i;
}
}
// then wrap the search to be from the beginning to the current row
for (int i = 0; i < start && counter < max; i++, counter++) {
if (textMatches(text, i, col)) {
return i;
}
}
return -1;
}
private int autoLookupBinary(String text) {
int index = binarySearch(text);
int col = lookupColumn;
if (textMatches(text, index, col)) {
return index;
}
if (index - 1 >= 0) {
if (textMatches(text, index - 1, col)) {
return index - 1;
}
}
if (index + 1 < getRowCount()) {
if (textMatches(text, index + 1, col)) {
return index + 1;
}
}
return -1;
}
private int binarySearch(String text) {
int sortedOrder = 1;
// if sorted descending, then reverse the search direction and change the lookup text to
// so that a match will come after the range we seek, which is before the desired text
// when sorted in reverse
if (!isSortedAscending()) {
sortedOrder = -1;
int lastPos = text.length() - 1;
char lastChar = text.charAt(lastPos);
++lastChar;
text = text.substring(0, lastPos) + lastChar;
}
int min = 0;
int rows = getRowCount();
int max = rows - 1;
int col = lookupColumn;
while (min < max) {
// divide by 2; preserve the sign to prevent possible overflow issue
int mid = (min + max) >>> 1;
String value = getValueString(mid, col);
int compare = text.compareToIgnoreCase(value);
compare *= sortedOrder;
if (compare < 0) {
max = mid - 1;
}
else if (compare > 0) {
min = mid + 1;
}
else { // exact match
return mid;
}
}
return min;
}
private class AutoLookupItem {
private long lastTime;
private String text;
private boolean foundPreviousMatch;
private boolean skip;
void keyTyped(KeyEvent e) {
skip = false;
if (isIgnorableKeyEvent(e)) {
skip = true;
return;
}
String eventChar = Character.toString(e.getKeyChar());
long when = e.getWhen();
if (when - lastTime > keyTimeout) {
text = eventChar;
}
else {
text += eventChar;
if (!foundPreviousMatch) {
// The given character is being added to the previous search. If that search
// was fruitless, then so too will be this one, since we use a
// 'starts with' match.
skip = true;
}
}
lastTime = when;
}
void setFoundMatch(boolean foundMatch) {
foundPreviousMatch = foundMatch;
}
String getText() {
return text;
}
boolean shouldSkip() {
return skip;
}
}
}

View file

@ -18,12 +18,10 @@
*/
package docking.widgets.filechooser;
import static org.apache.commons.lang3.StringUtils.*;
import java.awt.*;
import java.awt.event.*;
import java.io.File;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
import javax.swing.*;
@ -47,10 +45,6 @@ class DirectoryList extends GList<File> implements GhidraFileChooserDirectoryMod
private JTextField listEditorField;
private JPanel listEditor;
private long keyTimeout = AUTO_LOOKUP_TIMEOUT;
private long lastLookupTime;
private String lastLookupText;
/** The file being edited */
private File editedFile;
@ -119,20 +113,7 @@ class DirectoryList extends GList<File> implements GhidraFileChooserDirectoryMod
if (e.getKeyCode() == KeyEvent.VK_ENTER) {
e.consume();
handleEnterKey();
return;
}
String eventChar = Character.toString(e.getKeyChar());
long when = e.getWhen();
if (when - lastLookupTime > keyTimeout) {
lastLookupText = eventChar;
}
else {
lastLookupText += eventChar;
}
lastLookupTime = when;
lookupText(lastLookupText);
}
});
@ -210,75 +191,6 @@ class DirectoryList extends GList<File> implements GhidraFileChooserDirectoryMod
add(listEditor);
}
private void lookupText(String text) {
if (text == null) {
return;
}
int row = getSelectedIndex();
int rows = getModel().getSize();
if (row >= 0 && row < rows - 1) {
if (text.length() == 1) {
// fresh search; ignore the current row, could be from a previous match
++row;
}
File file = getModel().getElementAt(row);
String name = chooser.getDisplayName(file);
if (!name.isEmpty() && startsWithIgnoreCase(name, text)) {
setSelectedFile(getFile(row));
return;
}
}
int index = autoLookupBinary(text);
if (index >= 0) {
setSelectedFile(getFile(index));
}
}
private int autoLookupBinary(String text) {
// caveat: for this search to work, the data must be ascending sorted
List<File> files = model.getAllFiles();
File key = new File(lastLookupText);
Comparator<File> comparator = (f1, f2) -> {
String n1 = chooser.getDisplayName(f1);
return compareIgnoreCase(n1, text);
};
int index = Collections.binarySearch(files, key, comparator);
if (index < 0) {
index = -index - 1;
}
File file = files.get(index);
String name = chooser.getDisplayName(file);
if (startsWithIgnoreCase(name, text)) {
return index;
}
int before = index - 1;
if (before >= 0) {
file = files.get(before);
name = chooser.getDisplayName(file);
if (startsWithIgnoreCase(name, text)) {
return before;
}
}
int after = index + 1;
if (after < files.size()) {
file = files.get(after);
name = chooser.getDisplayName(file);
if (startsWithIgnoreCase(name, text)) {
return after;
}
}
return -1;
}
private void handleEnterKey() {
int[] selectedIndices = getSelectedIndices();
@ -388,17 +300,6 @@ class DirectoryList extends GList<File> implements GhidraFileChooserDirectoryMod
}
}
/**
* Sets the delay between keystrokes after which each keystroke is considered a new lookup
* @param timeout the timeout
* @see #AUTO_LOOKUP_TIMEOUT
*/
public void setAutoLookupTimeout(long timeout) {
keyTimeout = timeout;
lastLookupText = null;
lastLookupTime = 0;
}
void setSelectedFiles(Iterable<File> files) {
List<Integer> indexes = new ArrayList<>();

View file

@ -39,8 +39,7 @@ import docking.widgets.list.GListCellRenderer;
import ghidra.framework.OperatingSystem;
import ghidra.framework.Platform;
import ghidra.framework.preferences.Preferences;
import ghidra.util.Msg;
import ghidra.util.SystemUtilities;
import ghidra.util.*;
import ghidra.util.exception.AssertException;
import ghidra.util.filechooser.*;
import ghidra.util.layout.PairLayout;
@ -142,8 +141,11 @@ public class GhidraFileChooser extends DialogComponentProvider
private static boolean initialized;
private static List<RecentGhidraFile> recentList = new ArrayList<>();
private HistoryList<File> history = new HistoryList<>(20, dir -> {
updateDirAndSelectFile(dir, null, false, false);
private HistoryList<HistoryEntry> history = new HistoryList<>(20, (files, previous) -> {
updateHistoryWithSelectedFiles(previous);
updateDirAndSelectFile(files.parentDir, files.getSelectedFile(), false, false);
updateNavigationButtons();
});
private File initialFile = null;
@ -196,6 +198,15 @@ public class GhidraFileChooser extends DialogComponentProvider
private boolean multiSelectionEnabled;
private FileChooserActionManager actionManager;
/**
* The last input component to take focus (the text field or file view).
*
* <p>This may annoy users that are using the keyboard to perform navigation operations via
* the toolbar buttons, as we will keep putting focus back into the last input item. We
* may need a way to set this field to null when the user is working in this fashion.
*/
private Component lastInputFocus;
private Worker worker = Worker.createGuiWorker();
private GFileChooserOptionsDialog optionsDialog = new GFileChooserOptionsDialog();
@ -376,6 +387,13 @@ public class GhidraFileChooser extends DialogComponentProvider
}
});
filenameTextField.addFocusListener(new FocusAdapter() {
@Override
public void focusGained(FocusEvent e) {
lastInputFocus = filenameTextField;
}
});
// This is a callback when the user has made a choice from the selection window.
selectionListener = new SelectionListener<>();
filenameTextField.addDropDownSelectionChoiceListener(selectionListener);
@ -558,6 +576,13 @@ public class GhidraFileChooser extends DialogComponentProvider
directoryList.setName("LIST");
directoryList.setBackground(BACKGROUND_COLOR);
directoryList.addFocusListener(new FocusAdapter() {
@Override
public void focusGained(FocusEvent e) {
lastInputFocus = directoryList;
}
});
directoryScroll = new JScrollPane(directoryList);
directoryScroll.getViewport().setBackground(BACKGROUND_COLOR);
directoryScroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER);
@ -1140,10 +1165,9 @@ public class GhidraFileChooser extends DialogComponentProvider
// SCR 4513 - exception if we don't cancel edits before changing the display
cancelEdits();
SystemUtilities.runSwingNow(() -> {
if (addToHistory) {
addToBackHistory(directory);
}
Swing.runNow(() -> {
updateHistory(directory, addToHistory);
if (directory.equals(MY_COMPUTER) || directory.equals(RECENT)) {
currentPathTextField.setText(getFilename(directory));
}
@ -1172,6 +1196,10 @@ public class GhidraFileChooser extends DialogComponentProvider
}
private void doSetSelectedFileAndUpdateDisplay(File file) {
if (lastInputFocus != null) {
lastInputFocus.requestFocusInWindow();
}
if (file == null) {
return;
}
@ -1327,7 +1355,7 @@ public class GhidraFileChooser extends DialogComponentProvider
* Displays the WAIT panel. It handles the Swing thread issues.
*/
private void setWaitPanelVisible(final boolean visible) {
SystemUtilities.runSwingLater(() -> {
Swing.runLater(() -> {
if (visible) {
card.show(cardPanel, CARD_WAIT);
}
@ -1350,23 +1378,38 @@ public class GhidraFileChooser extends DialogComponentProvider
upLevelButton.setEnabled(enable);
}
private void addToBackHistory(File dir) {
private void updateHistoryWithSelectedFiles(HistoryEntry historyEntry) {
File currentDir = currentDirectory();
File selectedFile = selectedFiles.getFile();
historyEntry.setSelectedFile(currentDir, selectedFile);
}
private void updateHistory(File dir, boolean addToHistory) {
if (!directoryExistsOrIsLogicalDirectory(dir)) {
return;
}
if (dir.equals(currentDir)) {
HistoryEntry historyEntry = history.getCurrentHistoryItem();
if (historyEntry != null) {
updateHistoryWithSelectedFiles(historyEntry);
if (historyEntry.isSameDir(dir)) {
// already recorded in history
return;
}
history.add(dir);
updateNavigationButtons();
}
/*package*/ HistoryList<File> getHistory() {
return history;
if (addToHistory) {
history.add(new HistoryEntry(dir, null));
updateNavigationButtons();
}
}
/*package*/ int getHistorySize() {
return history.size();
}
/** Returns true if the file exists on disk OR if it is a logical dir, like 'My Computer' */
@ -1429,6 +1472,13 @@ public class GhidraFileChooser extends DialogComponentProvider
directoryTable.setName("TABLE");
directoryTable.setBackground(BACKGROUND_COLOR);
directoryTable.addFocusListener(new FocusAdapter() {
@Override
public void focusGained(FocusEvent e) {
lastInputFocus = directoryTable;
}
});
JScrollPane scrollPane = new JScrollPane(directoryTable);
scrollPane.getViewport().setBackground(BACKGROUND_COLOR);
return scrollPane;
@ -1851,6 +1901,20 @@ public class GhidraFileChooser extends DialogComponentProvider
return showDetails;
}
String getInvalidFilenameMessage(String filename) {
switch (filename) {
case ".":
case "..":
return "Reserved name '" + filename + "'";
default:
Matcher m = GhidraFileChooser.INVALID_FILENAME_PATTERN.matcher(filename);
if (m.find()) {
return "Invalid characters: " + m.group();
}
}
return null;
}
//==================================================================================================
// Inner Classes
//==================================================================================================
@ -2105,19 +2169,57 @@ public class GhidraFileChooser extends DialogComponentProvider
public synchronized File getFile() {
return files.get(0);
}
@Override
public String toString() {
return files.toString();
}
}
String getInvalidFilenameMessage(String filename) {
switch (filename) {
case ".":
case "..":
return "Reserved name '" + filename + "'";
default:
Matcher m = GhidraFileChooser.INVALID_FILENAME_PATTERN.matcher(filename);
if (m.find()) {
return "Invalid characters: " + m.group();
/**
* Container class to manage history entries for a directory and any selected file
*/
private class HistoryEntry {
private File parentDir;
private File selectedFile;
HistoryEntry(File parentDir, File selectedFile) {
this.parentDir = parentDir;
this.selectedFile = selectedFile;
}
boolean isSameDir(File dir) {
return dir.equals(parentDir);
}
void setSelectedFile(File dir, File selectedFile) {
if (!parentDir.equals(dir)) {
// not my dir; don't save the selection
return;
}
if (selectedFile == null) {
// nothing to save
return;
}
File selectedParent = selectedFile.getParentFile();
if (parentDir.equals(selectedParent)) {
this.selectedFile = selectedFile;
}
}
return null;
File getSelectedFile() {
return selectedFile;
}
@Override
public String toString() {
String selectedFilesText = "";
if (selectedFile != null) {
selectedFilesText = " *" + selectedFile;
}
return parentDir.getName() + selectedFilesText;
}
}
}

View file

@ -15,12 +15,15 @@
*/
package docking.widgets.list;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.Vector;
import javax.swing.*;
import javax.swing.text.Position.Bias;
import docking.widgets.AutoLookup;
import docking.widgets.GComponent;
import docking.widgets.table.GTable;
/**
* A sub-class of JList that provides an auto-lookup feature.
@ -35,8 +38,7 @@ import docking.widgets.table.GTable;
*/
public class GList<T> extends JList<T> implements GComponent {
/**The timeout for the auto-lookup feature*/
public static final long AUTO_LOOKUP_TIMEOUT = GTable.AUTO_LOOKUP_TIMEOUT;
private GListAutoLookup<T> autoLookup = new GListAutoLookup<T>(this);
/**
* Constructs a <code>GhidraList</code> with an empty model.
@ -86,5 +88,28 @@ public class GList<T> extends JList<T> implements GComponent {
GComponent.setHTMLRenderingFlag((JComponent) getCellRenderer(), false);
}
addListSelectionListener(e -> ensureIndexIsVisible(getSelectedIndex()));
addKeyListener(new KeyAdapter() {
@Override
public void keyReleased(KeyEvent e) {
autoLookup.keyTyped(e);
}
});
}
/**
* Sets the delay between keystrokes after which each keystroke is considered a new lookup
* @param timeout the timeout
* @see AutoLookup#KEY_TYPING_TIMEOUT
*/
public void setAutoLookupTimeout(long timeout) {
autoLookup.setTimeout(timeout);
}
@Override
public int getNextMatch(String prefix, int startIndex, Bias bias) {
// disable the default lookup algorithm, as it does not use the renderer, but
// only the object's toString(), which will not always match what the user sees on screen
return -1;
}
}

View file

@ -0,0 +1,75 @@
/* ###
* IP: GHIDRA
*
* 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.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package docking.widgets.list;
import java.awt.Component;
import javax.swing.JLabel;
import javax.swing.ListCellRenderer;
import docking.widgets.AutoLookup;
/**
* {@link AutoLookup} implementation for {@link GList}s
*
* @param <T> the row type
*/
public class GListAutoLookup<T> extends AutoLookup {
private GList<T> list;
public GListAutoLookup(GList<T> list) {
this.list = list;
}
@Override
public int getCurrentRow() {
return list.getSelectedIndex();
}
@Override
public int getRowCount() {
return list.getModel().getSize();
}
@Override
public String getValueString(int row, int col) {
ListCellRenderer<? super T> renderer = list.getCellRenderer();
T value = list.getModel().getElementAt(row);
if (!(renderer instanceof JLabel)) {
return value.toString();
}
Component c = renderer.getListCellRendererComponent(list, value, row, false, false);
return ((JLabel) c).getText();
}
@Override
public boolean isSorted(int column) {
return true;
}
@Override
public boolean isSortedAscending() {
return true;
}
@Override
public void matchFound(int row) {
list.setSelectedIndex(row);
}
}

View file

@ -29,12 +29,11 @@ import javax.swing.*;
import javax.swing.event.*;
import javax.swing.table.*;
import org.apache.commons.lang3.StringUtils;
import docking.*;
import docking.action.*;
import docking.actions.KeyBindingUtils;
import docking.actions.ToolActions;
import docking.widgets.AutoLookup;
import docking.widgets.OptionDialog;
import docking.widgets.dialogs.SettingsDialog;
import docking.widgets.filechooser.GhidraFileChooser;
@ -84,15 +83,11 @@ public class GTable extends JTable {
private static final String LAST_EXPORT_FILE = "LAST_EXPORT_DIR";
private static final KeyStroke ESCAPE = KeyStroke.getKeyStroke("ESCAPE");
public static final long AUTO_LOOKUP_TIMEOUT = 800;
private static final int AUTO_LOOKUP_MAX_SEARCH_ROWS = 50000;
private long keyTimeout = AUTO_LOOKUP_TIMEOUT;
private boolean isInitialized;
private boolean enableActionKeyBindings;
private KeyListener autoLookupListener;
private AutoLookupResult lastLookup;
private int lookupColumn = -1;
private GTableAutoLookup autoLookup = new GTableAutoLookup(this);
/** A list of default renderers created by this table */
protected List<TableCellRenderer> defaultGTableRendererList = new ArrayList<>();
@ -276,144 +271,14 @@ public class GTable extends JTable {
}
}
private String getValueString(int row, int col) {
TableCellRenderer renderer = getCellRenderer(row, col);
if (renderer instanceof JLabel) {
prepareRenderer(renderer, row, col);
return ((JLabel) renderer).getText();
}
Object obj = getValueAt(row, col);
return obj == null ? null : obj.toString();
}
private int lookupText(String text) {
if (text == null) {
return -1;
}
int row = getSelectedRow();
if (row >= 0 && row < getRowCount() - 1) {
if (text.length() == 1) {
// fresh search; ignore the current row, could be from a previous match
++row;
}
int col = convertColumnIndexToView(lookupColumn);
if (textMatches(text, row, col)) {
return row;
}
}
if (dataModel instanceof SortedTableModel) {
SortedTableModel sortedModel = (SortedTableModel) dataModel;
if (lookupColumn == sortedModel.getPrimarySortColumnIndex()) {
return autoLookupBinary(sortedModel, text);
}
}
return autoLookupLinear(text);
}
private boolean textMatches(String text, int row, int col) {
String value = getValueString(row, col);
return StringUtils.startsWithIgnoreCase(value, text);
}
private int autoLookupLinear(String text) {
int max = AUTO_LOOKUP_MAX_SEARCH_ROWS;
int rows = getRowCount();
int start = getSelectedRow();
int counter = 0;
int col = convertColumnIndexToView(lookupColumn);
// first search from the current row until the last row
for (int i = start + 1; i < rows && counter < max; i++, counter++) {
if (textMatches(text, i, col)) {
return i;
}
}
// then wrap the search to be from the beginning to the current row
for (int i = 0; i < start && counter < max; i++, counter++) {
if (textMatches(text, i, col)) {
return i;
}
}
return -1;
}
private int autoLookupBinary(SortedTableModel model, String text) {
int index = binarySearch(model, text);
int col = convertColumnIndexToView(lookupColumn);
if (textMatches(text, index, col)) {
return index;
}
if (index - 1 >= 0) {
if (textMatches(text, index - 1, col)) {
return index - 1;
}
}
if (index + 1 < model.getRowCount()) {
if (textMatches(text, index + 1, col)) {
return index + 1;
}
}
return -1;
}
private int binarySearch(SortedTableModel model, String text) {
int sortedOrder = 1;
int primarySortColumnIndex = model.getPrimarySortColumnIndex();
TableSortState columnSortState = model.getTableSortState();
ColumnSortState sortState = columnSortState.getColumnSortState(primarySortColumnIndex);
// if sorted descending, then reverse the search direction and change the lookup text to
// so that a match will come after the range we seek, which is before the desired text
// when sorted in reverse
if (!sortState.isAscending()) {
sortedOrder = -1;
int lastPos = text.length() - 1;
char lastChar = text.charAt(lastPos);
++lastChar;
text = text.substring(0, lastPos) + lastChar;
}
int min = 0;
int rows = model.getRowCount();
int max = rows - 1;
int col = convertColumnIndexToView(lookupColumn);
while (min < max) {
int mid = (min + max) / 2;
String value = getValueString(mid, col);
int compare = text.compareToIgnoreCase(value);
compare *= sortedOrder;
if (compare < 0) {
max = mid - 1;
}
else if (compare > 0) {
min = mid + 1;
}
else { // exact match
return mid;
}
}
return min;
}
/**
* Sets the delay between keystrokes after which each keystroke is considered a new lookup
* @param timeout the timeout
* @see #setAutoLookupColumn(int)
* @see #AUTO_LOOKUP_TIMEOUT
* @see AutoLookup#KEY_TYPING_TIMEOUT
*/
public void setAutoLookupTimeout(long timeout) {
keyTimeout = timeout;
lastLookup = null;
autoLookup.setTimeout(timeout);
}
/**
@ -426,7 +291,7 @@ public class GTable extends JTable {
* @param lookupColumn the column in which auto-lookup will be enabled
*/
public void setAutoLookupColumn(int lookupColumn) {
this.lookupColumn = lookupColumn;
autoLookup.setColumn(convertColumnIndexToView(lookupColumn));
if (autoLookupListener == null) {
autoLookupListener = new KeyAdapter() {
@ -441,25 +306,7 @@ public class GTable extends JTable {
return;
}
AutoLookupResult lookup = lastLookup;
if (lookup == null) {
lookup = new AutoLookupResult();
}
lookup.keyTyped(e);
if (lookup.shouldSkip()) {
return;
}
int row = lookupText(lookup.getText());
lookup.setFoundMatch(row >= 0);
if (row >= 0) {
setRowSelectionInterval(row, row);
Rectangle rect = getCellRect(row, 0, false);
scrollRectToVisible(rect);
}
lastLookup = lookup;
autoLookup.keyTyped(e);
}
};
}
@ -473,30 +320,6 @@ public class GTable extends JTable {
}
}
private boolean isIgnorableKeyEvent(KeyEvent event) {
// ignore modified keys, except for SHIFT
if (!isUnmodifiedOrShift(event.getModifiersEx())) {
return true;
}
if (event.isActionKey() || event.getKeyChar() == KeyEvent.CHAR_UNDEFINED ||
Character.isISOControl(event.getKeyChar())) {
return true;
}
return false;
}
private boolean isUnmodifiedOrShift(int modifiers) {
if (modifiers == 0) {
return true;
}
int shift = InputEvent.SHIFT_DOWN_MASK;
return (modifiers | shift) != shift;
}
/**
* Enables the keyboard actions to pass through this table and up the component hierarchy.
* Specifically, passing true to this method allows unmodified keystrokes to work
@ -1575,50 +1398,4 @@ public class GTable extends JTable {
return sourceComponent instanceof GTable;
}
}
private class AutoLookupResult {
private long lastTime;
private String text;
private boolean foundPreviousMatch;
private boolean skip;
public void keyTyped(KeyEvent e) {
skip = false;
if (isIgnorableKeyEvent(e)) {
skip = true;
return;
}
String eventChar = Character.toString(e.getKeyChar());
long when = e.getWhen();
if (when - lastTime > keyTimeout) {
text = eventChar;
}
else {
text += eventChar;
if (!foundPreviousMatch) {
// The given character is being added to the previous search. If that search
// was fruitless, then so too will be this one, since we use a
// 'starts with' match.
skip = true;
}
}
lastTime = when;
}
void setFoundMatch(boolean foundMatch) {
foundPreviousMatch = foundMatch;
}
String getText() {
return text;
}
boolean shouldSkip() {
return skip;
}
}
}

View file

@ -0,0 +1,79 @@
/* ###
* IP: GHIDRA
*
* 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.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package docking.widgets.table;
import java.awt.Rectangle;
import javax.swing.JLabel;
import javax.swing.table.TableCellRenderer;
import docking.widgets.AutoLookup;
/**
* {@link AutoLookup} implementation for {@link GTable}s
*/
public class GTableAutoLookup extends AutoLookup {
private GTable table;
GTableAutoLookup(GTable table) {
this.table = table;
}
@Override
public int getCurrentRow() {
return table.getSelectedRow();
}
@Override
public int getRowCount() {
return table.getRowCount();
}
@Override
public String getValueString(int row, int col) {
TableCellRenderer renderer = table.getCellRenderer(row, col);
if (renderer instanceof JLabel) {
table.prepareRenderer(renderer, row, col);
return ((JLabel) renderer).getText();
}
Object obj = table.getValueAt(row, col);
return obj == null ? null : obj.toString();
}
@Override
public boolean isSorted(int column) {
SortedTableModel sortedModel = (SortedTableModel) table.getModel();
return column == sortedModel.getPrimarySortColumnIndex();
}
@Override
public boolean isSortedAscending() {
SortedTableModel model = (SortedTableModel) table.getModel();
int primarySortColumnIndex = model.getPrimarySortColumnIndex();
TableSortState columnSortState = model.getTableSortState();
ColumnSortState sortState = columnSortState.getColumnSortState(primarySortColumnIndex);
return sortState.isAscending();
}
@Override
public void matchFound(int row) {
table.setRowSelectionInterval(row, row);
Rectangle rect = table.getCellRect(row, 0, false);
table.scrollRectToVisible(rect);
}
}

View file

@ -59,7 +59,6 @@ import ghidra.util.filechooser.ExtensionFileFilter;
import ghidra.util.filechooser.GhidraFileChooserModel;
import ghidra.util.worker.Worker;
import util.CollectionUtils;
import util.HistoryList;
import utilities.util.FileUtilities;
public class GhidraFileChooserTest extends AbstractDockingTest {
@ -123,7 +122,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
setFile(testDir);
for (int i = files.size() - 1; i >= 0; --i) {
pressBackButton();
pressBack();
File parentFile = files.get(i).getParentFile();
waitForChooser();
assertEquals(
@ -148,8 +147,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
public void testBackForSCR_3392() throws Exception {
File currentDirectory = getCurrentDirectory();
pressButtonByName(chooser.getComponent(), "MY_COMPUTER_BUTTON", false);
waitForChooser();
pressMyComputer();
File newCurrentDirectory = getCurrentDirectory();
assertFalse(currentDirectory.equals(newCurrentDirectory));
@ -157,7 +155,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
JButton backButton = (JButton) getInstanceField("backButton", chooser);
assertTrue(backButton.isEnabled());
pressBackButton();
pressBack();
waitForChooser();
newCurrentDirectory = getCurrentDirectory();
@ -190,12 +188,12 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
waitForChooser();
// back should now go to the 'home' dir
pressBackButton();
pressBack();
waitForChooser();
assertEquals("Did not go back to the home directory", homeDir, getCurrentDirectory());
// finally, go back to the start dir
pressBackButton();
pressBack();
waitForChooser();
assertEquals("Did not go back to the start directory", startDir.getParentFile(),
getCurrentDirectory());
@ -257,9 +255,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
(JTextField) findComponentByName(chooser.getComponent(), "LIST_EDITOR_FIELD");
assertNotNull(editorField);
// Now, press the "My Computer" button...
pressButtonByName(chooser.getComponent(), "MY_COMPUTER_BUTTON", false);
waitForChooser();
pressMyComputer();
// Next, double-click the root drive entry
DirectoryListModel model = (DirectoryListModel) dirlist.getModel();
@ -403,8 +399,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
public void testRefresh() throws Exception {
setDir(tempdir);
HistoryList<File> history = chooser.getHistory();
int size = history.size();
int size = chooser.getHistorySize();
File tempfile = File.createTempFile(getName(), ".tmp", tempdir);
tempfile.deleteOnExit();
@ -413,18 +408,18 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
DirectoryListModel dirmodel = (DirectoryListModel) dirlist.getModel();
assertFalse(dirmodel.contains(tempfile));
pressRefreshButton();
pressRefresh();
waitForChooser();
assertTrue(dirmodel.contains(tempfile));
assertTrue(tempfile.delete());
pressRefreshButton();
pressRefresh();
waitForChooser();
assertFalse(dirmodel.contains(tempfile));
// verify back stack is not corrupted!!!
assertEquals(size, history.size());
assertEquals(size, size = chooser.getHistorySize());
}
// refresh was navigating into the selected directory
@ -447,7 +442,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
});
// press refresh
pressRefreshButton();
pressRefresh();
waitForFile(newFile, DEFAULT_TIMEOUT_MILLIS);
// verify we did not go into the selected directory
@ -961,7 +956,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
@Test
public void testShowDetails() throws Exception {
JPanel cardPanel = (JPanel) findComponentByName(chooser.getComponent(), "CARD_PANEL");
JList<?> dirlist = (JList<?>) findComponentByName(chooser.getComponent(), "LIST");
DirectoryList dirlist = getListView();
JTable dirtable = getTableView();
JScrollPane scrollpane1 = (JScrollPane) cardPanel.getComponent(0);
JScrollPane scrollpane2 = (JScrollPane) cardPanel.getComponent(1);
@ -1048,8 +1043,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
}
setMode(DIRECTORIES_ONLY);
pressButtonByName(chooser.getComponent(), "RECENT_BUTTON", false);
waitForChooser();
pressRecent();
// make a selection
File fileToSelect = files.get(0).getParentFile();
@ -1067,9 +1061,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
// make sure the user cannot type in relative filenames when in a special dir (like
// Recent and My Computer)
// press the recent button
pressButtonByName(chooser.getComponent(), "RECENT_BUTTON", false);
waitForChooser();
pressRecent();
setFilenameFieldText("foo");
@ -1078,8 +1070,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
assertTrue("The file chooser accepted an invalid file parented by the RECENTs directory",
chooser.isShowing());
pressButtonByName(chooser.getComponent(), "MY_COMPUTER_BUTTON", false);
waitForChooser();
pressMyComputer();
setFilenameFieldText("foo");
@ -1340,10 +1331,9 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
@Test
public void testMyComputer() throws Exception {
pressButtonByName(chooser.getComponent(), "MY_COMPUTER_BUTTON", false);
waitForChooser();
pressMyComputer();
JList<?> dirlist = (JList<?>) findComponentByName(chooser.getComponent(), "LIST");
DirectoryList dirlist = getListView();
DirectoryListModel listModel = (DirectoryListModel) dirlist.getModel();
File[] roots = chooser.getModel().getRoots();
assertEquals(roots.length, listModel.getSize());
@ -1455,7 +1445,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
getCurrentDirectory());
// check the chooser contents
JList<?> dirlist = (JList<?>) findComponentByName(chooser.getComponent(), "LIST");
DirectoryList dirlist = getListView();
DirectoryListModel listModel = (DirectoryListModel) dirlist.getModel();
File[] listing = chooser.getModel().getListing(homeDir, null);
assertEquals(listing.length, listModel.getSize());
@ -1473,10 +1463,9 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
show();
}
pressButtonByName(chooser.getComponent(), "RECENT_BUTTON", false);
waitForChooser();
pressRecent();
JList<?> dirlist = (JList<?>) findComponentByName(chooser.getComponent(), "LIST");
DirectoryList dirlist = getListView();
DirectoryListModel listModel = (DirectoryListModel) dirlist.getModel();
for (File element : files) {
assertTrue("model does not contain the recent file: " + element.getParentFile(),
@ -1504,8 +1493,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
show();
}
pressButtonByName(chooser.getComponent(), "RECENT_BUTTON", false);
waitForChooser();
pressRecent();
// must re-retrieve the action, since we created a new chooser
actionManager = chooser.getActionManager();
@ -1531,7 +1519,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
@Test
public void testFileFilter() throws Exception {
JList<?> dirlist = (JList<?>) findComponentByName(chooser.getComponent(), "LIST");
DirectoryList dirlist = getListView();
DirectoryListModel listModel = (DirectoryListModel) dirlist.getModel();
runSwing(() -> chooser.setFileFilter(new ExtensionFileFilter("exe", "Executables")));
@ -1642,7 +1630,7 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
showMultiSelectionChooser(files.parent, FILES_ONLY);
setTableMode();
GTable table = getTableView();
DirectoryTable table = getTableView();
int testTimeoutMs = 100;
table.setAutoLookupTimeout(testTimeoutMs);
@ -1733,6 +1721,42 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
assertSelectedIndex(list, 0);
}
@Test
public void testFocus_FilesViewStaysFocusedAfterRefresh() throws Exception {
DirectoryList list = getListView();
focus(list);
clickMyComputer();
assertTrue(list.hasFocus());
clickRecent();
assertTrue(list.hasFocus());
clickBack();
assertTrue(list.hasFocus());
}
@Test
public void testHistoryRestoresSelectedFiles() throws Exception {
File startDir = createTempDir();
setDir(startDir);
createFileSubFile(startDir, 3);
pressUp();
selectFile(getListView(), 1);
pressUp();
selectFile(getListView(), 2);
pressBack();
assertSelectedIndex(getListView(), 1);
pressForward();
assertSelectedIndex(getListView(), 2);
}
@Test
public void testGetSelectedFiles_FileOnlyMode_FileSelected() throws Exception {
@ -1892,12 +1916,14 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
assertEquals("Wrong table row selected", expected, actual);
}
private void selectFile(DirectoryList list, int index) {
private File selectFile(DirectoryList list, int index) {
runSwing(() -> list.setSelectedIndex(index));
return runSwing(() -> list.getSelectedFile());
}
private void selectFile(GTable table, int index) {
private File selectFile(DirectoryTable table, int index) {
runSwing(() -> table.getSelectionModel().setSelectionInterval(index, index));
return runSwing(() -> table.getSelectedFile());
}
private void focus(Component c) {
@ -1970,6 +1996,37 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
waitForSwing();
}
private void pressMyComputer() throws Exception {
pressButtonByName(chooser.getComponent(), "MY_COMPUTER_BUTTON", false);
waitForChooser();
}
private void clickMyComputer() throws Exception {
AbstractButton button =
(AbstractButton) findComponentByName(chooser.getComponent(), "MY_COMPUTER_BUTTON");
leftClick(button, 5, 5);
waitForChooser();
}
private void clickRecent() throws Exception {
AbstractButton button =
(AbstractButton) findComponentByName(chooser.getComponent(), "RECENT_BUTTON");
leftClick(button, 5, 5);
waitForChooser();
}
private void clickBack() throws Exception {
AbstractButton button =
(AbstractButton) findComponentByName(chooser.getComponent(), "BACK_BUTTON");
leftClick(button, 5, 5);
waitForChooser();
}
private void pressRecent() throws Exception {
pressButtonByName(chooser.getComponent(), "RECENT_BUTTON", false);
waitForChooser();
}
private void pressHome() {
pressButtonByName(chooser.getComponent(), "HOME_BUTTON", false);
waitForSwing();
@ -1985,14 +2042,27 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
waitForSwing();
}
private void pressRefreshButton() {
private void pressRefresh() {
pressButtonByName(chooser.getComponent(), "REFRESH_BUTTON");
waitForSwing();
}
private void pressBackButton() {
private void pressBack() throws Exception {
pressButtonByName(chooser.getComponent(), "BACK_BUTTON");
waitForSwing();
waitForChooser();
}
private void pressForward() throws Exception {
pressButtonByName(chooser.getComponent(), "FORWARD_BUTTON");
waitForSwing();
waitForChooser();
}
private void pressUp() throws Exception {
pressButtonByName(chooser.getComponent(), "UP_BUTTON");
waitForSwing();
waitForChooser();
}
private void setDir(final File dir) throws Exception {
@ -2538,8 +2608,8 @@ public class GhidraFileChooserTest extends AbstractDockingTest {
return (DirectoryList) findComponentByName(chooser.getComponent(), "LIST");
}
private GTable getTableView() {
return (GTable) findComponentByName(chooser.getComponent(), "TABLE");
private DirectoryTable getTableView() {
return (DirectoryTable) findComponentByName(chooser.getComponent(), "TABLE");
}
private void debugChooser() {

View file

@ -34,6 +34,7 @@ import javax.swing.event.TableModelEvent;
import org.junit.*;
import docking.DockingUtils;
import docking.widgets.AutoLookup;
import docking.widgets.filter.*;
import docking.widgets.table.*;
import docking.widgets.table.ColumnSortState.SortDirection;
@ -455,7 +456,7 @@ public class ThreadedTableTest extends AbstractThreadedTableTest {
triggerText(table, "si");
assertEquals(6, table.getSelectedRow());
sleep(GTable.AUTO_LOOKUP_TIMEOUT);
sleep(AutoLookup.KEY_TYPING_TIMEOUT);
// try again with the sort in the other direction
selectFirstRow();

View file

@ -15,10 +15,10 @@
*/
package ghidra.util;
import java.util.*;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
@ -555,9 +555,9 @@ public class StringUtilities {
length *= -1;
}
int numFillers = length - source.length();
StringBuffer buffer = new StringBuffer();
for (int f = 0; f < numFillers; f++) {
int n = length - source.length();
StringBuilder buffer = new StringBuilder();
for (int i = 0; i < n; i++) {
buffer.append(filler);
}
@ -756,23 +756,6 @@ public class StringUtilities {
return new String(bytes);
}
/**
* Turn the given data into an attractive string, with the separator of your choosing
*
* @param collection the data from which a string will be generated
* @param separator the string used to separate elements
* @return a string representation of the given list
*/
public static String toString(Collection<?> collection, String separator) {
if (collection == null) {
return null;
}
String asString =
collection.stream().map(o -> o.toString()).collect(Collectors.joining(separator));
return "[ " + asString + " ]";
}
public static String toStringWithIndent(Object o) {
if (o == null) {
return "null";
@ -783,19 +766,6 @@ public class StringUtilities {
return indented;
}
/**
* Reverse the characters in the given string
*
* @param s the string to reverse
* @return the reversed string
*/
public static String reverse(String s) {
if (s == null) {
return null;
}
return new StringBuilder(s).reverse().toString();
}
/**
* Merge two strings into one.
* If one string contains the other, then the largest is returned.

View file

@ -16,8 +16,11 @@
package util;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.apache.commons.lang3.StringUtils;
import ghidra.util.SystemUtilities;
import ghidra.util.datastruct.FixedSizeStack;
@ -47,7 +50,7 @@ import ghidra.util.datastruct.FixedSizeStack;
public class HistoryList<T> {
private final FixedSizeStack<T> historyStack;
private final Consumer<T> itemSelectedCallback;
private final BiConsumer<T, T> itemSelectedCallback;
private int historyIndex;
private boolean isBroadcasting;
@ -64,6 +67,20 @@ public class HistoryList<T> {
* going back or forward
*/
public HistoryList(int size, Consumer<T> itemSelectedCallback) {
this(size, asBiConsumer(itemSelectedCallback));
}
/**
* The sized passed here limits the size of the list, with the oldest items being dropped
* as the list grows. The given callback will be called when {@link #goBack()} or
* {@link #goForward()} are called.
*
* @param size the max number of items to keep in the list
* @param itemSelectedCallback the function to call when the client selects an item by
* going back or forward. This callback will be passed the newly selected item as
* the first argument and the previously selected item as the second argument.
*/
public HistoryList(int size, BiConsumer<T, T> itemSelectedCallback) {
Objects.requireNonNull(itemSelectedCallback, "Item selected callback cannot be null");
if (size < 1) {
@ -74,6 +91,10 @@ public class HistoryList<T> {
this.historyStack = new FixedSizeStack<>(size);
}
private static <T> BiConsumer<T, T> asBiConsumer(Consumer<T> consumer) {
return (t, ignored) -> consumer.accept(t);
}
//==================================================================================================
// Interface Methods
//==================================================================================================
@ -170,9 +191,10 @@ public class HistoryList<T> {
return;
}
T leaving = getCurrentHistoryItem();
T t = historyStack.get(--historyIndex);
dropNull();
broadcast(t);
broadcast(t, leaving);
}
/**
@ -198,8 +220,9 @@ public class HistoryList<T> {
return;
}
T leaving = getCurrentHistoryItem();
T t = historyStack.get(++historyIndex);
broadcast(t);
broadcast(t, leaving);
}
/**
@ -342,10 +365,10 @@ public class HistoryList<T> {
historyStack.remove(itemIndex);
}
private void broadcast(T t) {
private void broadcast(T t, T leaving) {
try {
isBroadcasting = true;
itemSelectedCallback.accept(t);
itemSelectedCallback.accept(t, leaving);
}
finally {
isBroadcasting = false;
@ -362,6 +385,9 @@ public class HistoryList<T> {
@Override
public String toString() {
String key = " items: ";
String newlinePad = StringUtils.repeat(' ', key.length());
StringBuilder buffy = new StringBuilder();
for (int i = 0; i < historyStack.size(); i++) {
T t = historyStack.get(i);
@ -377,13 +403,13 @@ public class HistoryList<T> {
}
if (i != historyStack.size() - 1) {
buffy.append(',').append(' ');
buffy.append(',').append('\n').append(newlinePad);
}
}
//@formatter:off
return "{\n" +
"\titems: " + buffy.toString() + "\n" +
key + buffy.toString() + "\n" +
"}";
//@formatter:on
}

View file

@ -248,18 +248,6 @@ public class StringUtilitiesTest {
StringUtilities.trimMiddle(overString, max);
}
@Test
public void testReverse() {
String hello = "hello";
String reversed = StringUtilities.reverse(hello);
assertEquals("olleh", reversed);
}
@Test
public void testReverseNull() {
assertNull(StringUtilities.reverse(null));
}
@Test
public void testGetLastWord() {
assertEquals("word", StringUtilities.getLastWord("/This/is/my/last/word", "/"));