diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/ArchiveDialog.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/ArchiveDialog.java index 3931c2d21d..0f9b3d42f9 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/ArchiveDialog.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/ArchiveDialog.java @@ -22,9 +22,11 @@ import javax.swing.*; import docking.ReusableDialogComponentProvider; import docking.widgets.OptionDialog; +import docking.widgets.button.BrowseButton; import docking.widgets.filechooser.GhidraFileChooser; import docking.widgets.filechooser.GhidraFileChooserMode; import docking.widgets.label.GDLabel; +import docking.widgets.textfield.ElidingFilePathTextField; import generic.theme.Gui; import ghidra.framework.GenericRunInfo; import ghidra.framework.model.ProjectLocator; @@ -76,11 +78,11 @@ public class ArchiveDialog extends ReusableDialogComponentProvider { JPanel outerPanel = new JPanel(gbl); outerPanel.getAccessibleContext().setAccessibleName("Archive"); archiveLabel = new GDLabel(" Archive File "); - archiveField = new JTextField(); + archiveField = new ElidingFilePathTextField(); archiveField.setName("archiveField"); archiveField.getAccessibleContext().setAccessibleName("Archive Field"); archiveField.setColumns(NUM_TEXT_COLUMNS); - archiveBrowse = new JButton(ArchivePlugin.DOT_DOT_DOT); + archiveBrowse = new BrowseButton(); archiveBrowse.addActionListener(e -> { archivePathName = archiveField.getText().trim(); String archName = chooseArchiveFile("Choose archive file", "Selects the archive file"); diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/ArchivePlugin.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/ArchivePlugin.java index cf066d3d2c..607b94ada3 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/ArchivePlugin.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/ArchivePlugin.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. @@ -58,7 +58,6 @@ public class ArchivePlugin extends Plugin implements ApplicationLevelOnlyPlugin, static final String TOOL_RUNNING_TITLE = "Cannot Archive while Tools are Running"; static final String GROUP_NAME = "Archiving"; static final String ARCHIVE_EXTENSION = ".gar"; - static final String DOT_DOT_DOT = ". . ."; static final String TOOLS_FOLDER_NAME = "tools"; static final String GROUPS_FOLDER_NAME = "groups"; static final String SAVE_FOLDER_NAME = "save"; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/RestoreDialog.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/RestoreDialog.java index a6d837638e..60397a0659 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/RestoreDialog.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/archive/RestoreDialog.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. @@ -23,9 +23,11 @@ import java.io.File; import javax.swing.*; import docking.ReusableDialogComponentProvider; +import docking.widgets.button.BrowseButton; import docking.widgets.filechooser.GhidraFileChooser; import docking.widgets.filechooser.GhidraFileChooserMode; import docking.widgets.label.GDLabel; +import docking.widgets.textfield.ElidingFilePathTextField; import generic.theme.Gui; import ghidra.framework.GenericRunInfo; import ghidra.framework.model.ProjectLocator; @@ -79,12 +81,12 @@ public class RestoreDialog extends ReusableDialogComponentProvider { // Create the individual components that make up the panel. archiveLabel = new GDLabel(" Archive File "); archiveLabel.getAccessibleContext().setAccessibleName("Archive File"); - archiveField = new JTextField(); + archiveField = new ElidingFilePathTextField(); archiveField.setColumns(NUM_TEXT_COLUMNS); archiveField.setName("archiveField"); archiveField.getAccessibleContext().setAccessibleName("Archive"); - archiveBrowse = new JButton(ArchivePlugin.DOT_DOT_DOT); + archiveBrowse = new BrowseButton(); archiveBrowse.setName("archiveButton"); archiveBrowse.getAccessibleContext().setAccessibleName("Archive"); archiveBrowse.addActionListener(new ActionListener() { @@ -121,12 +123,12 @@ public class RestoreDialog extends ReusableDialogComponentProvider { restoreLabel = new GDLabel(" Restore Directory "); restoreLabel.getAccessibleContext().setAccessibleName("Restore Directory"); - restoreField = new JTextField(); + restoreField = new ElidingFilePathTextField(); restoreField.setName("restoreField"); restoreField.getAccessibleContext().setAccessibleName("Restore"); restoreField.setColumns(RestoreDialog.NUM_TEXT_COLUMNS); - restoreBrowse = new JButton(ArchivePlugin.DOT_DOT_DOT); + restoreBrowse = new BrowseButton(); restoreBrowse.setName("restoreButton"); restoreBrowse.getAccessibleContext().setAccessibleName("Restore Browse"); restoreBrowse.addActionListener(e -> { diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/exporter/ExporterDialog.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/exporter/ExporterDialog.java index ccc726efb9..6b6ce75542 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/exporter/ExporterDialog.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/exporter/ExporterDialog.java @@ -33,6 +33,7 @@ import docking.widgets.combobox.GhidraComboBox; import docking.widgets.filechooser.GhidraFileChooser; import docking.widgets.filechooser.GhidraFileChooserMode; import docking.widgets.label.GLabel; +import docking.widgets.textfield.ElidingFilePathTextField; import ghidra.app.plugin.core.help.AboutDomainObjectUtils; import ghidra.app.util.*; import ghidra.app.util.exporter.Exporter; @@ -92,7 +93,7 @@ public class ExporterDialog extends DialogComponentProvider implements AddressFa /** * Show a new ExporterDialog for exporting an entire program. * The method {@link #hasNoApplicableExporter()} should be checked before showing the - * dilaog. If no exporters are available a popup error will be displayed and the exporter + * dialog. If no exporters are available a popup error will be displayed and the exporter * dialog will not be shown. * * @param tool the tool that launched this dialog. @@ -105,7 +106,7 @@ public class ExporterDialog extends DialogComponentProvider implements AddressFa /** * Construct a new ExporterDialog for exporting a program, optionally only exported a * selected region. The method {@link #hasNoApplicableExporter()} should be checked before - * showing the dilaog. If no exporters are available a popup error will be displayed and the + * showing the dialog. If no exporters are available a popup error will be displayed and the * exporter dialog will not be shown. * The {@link #close()} method must always be invoked on the dialog instance even if it * is never shown to ensure any {@link DomainObject} instance held is properly released. @@ -129,7 +130,7 @@ public class ExporterDialog extends DialogComponentProvider implements AddressFa /** * Construct a new modal ExporterDialog for exporting a program, optionally only exported a * selected region. The method {@link #hasNoApplicableExporter()} should be checked before - * showing the dilaog. If no exporters are available a popup error will be displayed. + * showing the dialog. If no exporters are available a popup error will be displayed. * The {@link #close()} method must always be invoked on the dialog instance even if it * is never shown to ensure any {@link DomainObject} instance held is properly released. * @@ -275,7 +276,7 @@ public class ExporterDialog extends DialogComponentProvider implements AddressFa } private Component buildFilePanel() { - filePathTextField = new JTextField(); + filePathTextField = new ElidingFilePathTextField(); filePathTextField.setName("OUTPUT_FILE_TEXTFIELD"); filePathTextField.getAccessibleContext().setAccessibleName("Output File"); filePathTextField.setText(getFileName()); @@ -601,7 +602,7 @@ public class ExporterDialog extends DialogComponentProvider implements AddressFa return; } - // Program selection only relavent if isFrontEndPlugin() is false + // Program selection only relevant if isFrontEndPlugin() is false ProgramSelection selection = getApplicableProgramSelection(); File outputFile = getSelectedOutputFile(); diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/util/importer/DomainFolderOption.java b/Ghidra/Features/Base/src/main/java/ghidra/app/util/importer/DomainFolderOption.java index 43726640ff..5394d66ca7 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/util/importer/DomainFolderOption.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/util/importer/DomainFolderOption.java @@ -23,6 +23,7 @@ import java.awt.Component; import javax.swing.*; import docking.widgets.button.BrowseButton; +import docking.widgets.textfield.ElidingFilePathTextField; import ghidra.app.util.Option; import ghidra.app.util.opinion.Loader; import ghidra.framework.main.AppInfo; @@ -53,8 +54,9 @@ public class DomainFolderOption extends Option { String lastFolderPath = state != null ? state.getString(getName(), defaultValue) : defaultValue; setValue(lastFolderPath); - JTextField textField = new JTextField(lastFolderPath); + JTextField textField = new ElidingFilePathTextField(lastFolderPath); textField.setEditable(false); + textField.setColumns(10); JButton button = new BrowseButton(); button.addActionListener(e -> { DataTreeDialog dataTreeDialog = diff --git a/Ghidra/Features/Base/src/main/java/ghidra/plugin/importer/ImporterDialog.java b/Ghidra/Features/Base/src/main/java/ghidra/plugin/importer/ImporterDialog.java index 7520aa3514..3410837d29 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/plugin/importer/ImporterDialog.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/plugin/importer/ImporterDialog.java @@ -39,6 +39,7 @@ import docking.widgets.combobox.GhidraComboBox; import docking.widgets.dialogs.MultiLineMessageDialog; import docking.widgets.label.GLabel; import docking.widgets.list.GComboBoxCellRenderer; +import docking.widgets.textfield.ElidingFilePathTextField; import generic.theme.GIcon; import generic.theme.Gui; import ghidra.app.services.ProgramManager; @@ -86,7 +87,7 @@ public class ImporterDialog extends DialogComponentProvider { protected JTextField languageTextField; protected JCheckBox mirrorFsCheckBox; protected JButton optionsButton; - protected JTextField folderNameTextField; + protected ElidingFilePathTextField folderNameTextField; protected GhidraComboBox loaderComboBox; /** @@ -228,7 +229,8 @@ public class ImporterDialog extends DialogComponentProvider { } private Component buildFolderNameField() { - folderNameTextField = new JTextField(); + folderNameTextField = new ElidingFilePathTextField(); + folderNameTextField.setColumns(20); folderNameTextField.setEditable(false); folderNameTextField.setFocusable(false); folderNameTextField.getAccessibleContext().setAccessibleName("Folder Name"); diff --git a/Ghidra/Features/Base/src/main/java/ghidra/plugins/importer/batch/BatchProjectDestinationPanel.java b/Ghidra/Features/Base/src/main/java/ghidra/plugins/importer/batch/BatchProjectDestinationPanel.java index 3aae03fecb..64d9750551 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/plugins/importer/batch/BatchProjectDestinationPanel.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/plugins/importer/batch/BatchProjectDestinationPanel.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. @@ -21,6 +21,7 @@ import javax.swing.*; import docking.widgets.button.BrowseButton; import docking.widgets.label.GDLabel; +import docking.widgets.textfield.ElidingFilePathTextField; import ghidra.framework.main.*; import ghidra.framework.model.*; @@ -43,7 +44,7 @@ class BatchProjectDestinationPanel extends JPanel { private void build() { setLayout(new BorderLayout()); - folderNameTextField = new JTextField(); + folderNameTextField = new ElidingFilePathTextField(); folderNameTextField.setEditable(false); folderNameTextField.setFocusable(false); folderNameTextField.setText(getProjectRootFolder().toString()); diff --git a/Ghidra/Features/PDB/src/main/java/pdb/symbolserver/ui/LoadPdbDialog.java b/Ghidra/Features/PDB/src/main/java/pdb/symbolserver/ui/LoadPdbDialog.java index 3ce96df01b..64284c0119 100644 --- a/Ghidra/Features/PDB/src/main/java/pdb/symbolserver/ui/LoadPdbDialog.java +++ b/Ghidra/Features/PDB/src/main/java/pdb/symbolserver/ui/LoadPdbDialog.java @@ -39,8 +39,8 @@ import docking.widgets.filechooser.GhidraFileChooser; import docking.widgets.filechooser.GhidraFileChooserMode; import docking.widgets.label.GIconLabel; import docking.widgets.label.GLabel; +import docking.widgets.textfield.ElidingFilePathTextField; import docking.widgets.textfield.HexOrDecimalInput; -import docking.widgets.textfield.HintTextField; import generic.theme.GIcon; import generic.theme.GThemeDefaults.Colors; import generic.theme.GThemeDefaults.Colors.Messages; @@ -127,7 +127,7 @@ public class LoadPdbDialog extends DialogComponentProvider { private GCheckBox overridePdbUniqueIdCheckBox; private HexOrDecimalInput pdbAgeTextField; private GCheckBox overridePdbAgeCheckBox; - private HintTextField pdbLocationTextField; + private ElidingFilePathTextField pdbLocationTextField; private GIconLabel exactMatchIconLabel; private JButton configButton; @@ -467,7 +467,8 @@ public class LoadPdbDialog extends DialogComponentProvider { } private JPanel buildPdbLocationPanel() { - pdbLocationTextField = new HintTextField("Browse [...] for PDB file or use 'Advanced'"); + pdbLocationTextField = + new ElidingFilePathTextField(null, "Browse [...] for PDB file or use 'Advanced'"); pdbLocationTextField.setEditable(false); pdbLocationTextField.getAccessibleContext().setAccessibleName("PDB Location"); diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/ElidingFilePathTextField.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/ElidingFilePathTextField.java new file mode 100644 index 0000000000..cb6c5483cf --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/ElidingFilePathTextField.java @@ -0,0 +1,174 @@ +/* ### + * 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.textfield; + +import java.awt.FontMetrics; +import java.util.ArrayList; +import java.util.List; + +/** + * {@link PreviewTextField} (JTextField) that has a preview that compresses / shortens the + * text in the field using rules that are tuned to preserve human readability of filename path info. + *

+ * Longer directory names are truncated and modified to have a "..." suffix. When adjacent + * directory names have been reduced to just "...", they are combined into a single "...." (4-dot). + *

+ * The first and last directory elements in the path are given preference and will be subject to + * shortening after interior directory name elements. + *

+ * The final element in the path (filename) is always preserved. + *

+ * If the preview of the path needs truncation, the full path will be temporarily appended to the + * the field's tool tip. + */ +public class ElidingFilePathTextField extends PreviewTextField { + private static final int ELLIPSE_LEN = "...".length(); + + /** + * Creates a new {@link ElidingFilePathTextField} instance with no text. + */ + public ElidingFilePathTextField() { + this(null, null); + } + + /** + * Creates a new {@link ElidingFilePathTextField} instance with specified text value. + * @param text string to assign as initial value of text field + */ + public ElidingFilePathTextField(String text) { + this(text, null); + } + + /** + * Creates a new {@link ElidingFilePathTextField} instance with specified text and hint values. + * @param text string to assign as initial value of text field + * @param hint string to assign as the hint value that is shown when the field is blank + */ + public ElidingFilePathTextField(String text, String hint) { + super(text, hint, false, null); + } + + record PathPartInfo(int origIndex, String s) { + int getLen(String[] pathParts) { + String partStr = pathParts[origIndex]; + return partStr != null ? partStr.length() : ELLIPSE_LEN; + } + + static int pathPartCompare(String[] pathParts, PathPartInfo ppi1, PathPartInfo ppi2, + boolean boostOutsideElements) { + int s1len = ppi1.getLen(pathParts); + int s2len = ppi2.getLen(pathParts); + if (boostOutsideElements) { + // make the first and last couple of elements in the path seem shorter than they are + // to tweak the output and preserve those elements if possible + if (ppi1.origIndex < 2 || ppi1.origIndex > pathParts.length - 3) { + s1len = s1len / 2; + } + if (ppi2.origIndex < 2 || ppi2.origIndex > pathParts.length - 3) { + s2len = s2len / 2; + } + } + return Integer.compare(s1len, s2len); + } + + } + + protected boolean isShortEnough(String s, FontMetrics fm, int maxWidth) { + return fm.stringWidth(s) < maxWidth; + } + + @Override + protected String getPreviewString(String s, FontMetrics fm, int maxWidth) { + String[] pathParts = s.split("/"); + if (pathParts.length < 2) { + return s; + } + + // list of path elements, sorted by string length, longer first + List sortedParts = new ArrayList<>(); + for (int i = 0; i < pathParts.length - 1 /* skip filename/last element */; i++) { + sortedParts.add(new PathPartInfo(i, pathParts[i])); + } + sortedParts.sort((s1, s2) -> PathPartInfo.pathPartCompare(pathParts, s2, s1, true)); + + String result = s; + // first try abbreviating the longer parts until the path is short enough + for (PathPartInfo ppi : sortedParts) { + if (ppi.getLen(pathParts) <= ELLIPSE_LEN) { + break; + } + String part = pathParts[ppi.origIndex]; + for (int i = part.length() - ELLIPSE_LEN; i >= 0; i--) { + pathParts[ppi.origIndex] = i > 0 ? part.substring(0, i) + "..." : null; + result = partsToString(pathParts); + if (isShortEnough(result, fm, maxWidth)) { + return result; // success + } + } + } + + // finally just start indiscriminately removing elements until it fits + for (PathPartInfo ppi : sortedParts) { + if (pathParts[ppi.origIndex] == null) { + continue; + } + pathParts[ppi.origIndex] = null; + result = partsToString(pathParts); + if (isShortEnough(result, fm, maxWidth)) { + break; // fall thru, return result + } + } + + return result; + } + + private String partsToString(String[] pathParts) { + // create a pseudo path string from the array of path parts + // runs of null elements are represented by "....", single null element by "..." + // will have a leading '/' if the first element of the array is blank "" + StringBuilder sb = new StringBuilder(); + int nullrun = 0; + for (int i = 0; i < pathParts.length; i++) { + String part = pathParts[i]; + if (part != null) { + if (i == 0 && part.isEmpty()) { + // leading empty element means there was a leading '/' in the path + if (pathParts.length < 2 || pathParts[1] != null) { + // only output leading '/' if next path element is defined + part = "/"; + } + } + if (nullrun != 0) { + appendPath(sb, nullrun == 1 ? "..." : "...."); + nullrun = 0; + } + appendPath(sb, part); + } + else { + nullrun++; + } + } + return sb.toString(); + } + + private void appendPath(StringBuilder sb, String s) { + if (!sb.isEmpty() && sb.charAt(sb.length() - 1) != '/') { + sb.append('/'); + } + sb.append(s); + } + +} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/HintTextField.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/HintTextField.java index 7deeb35e86..b1813ef1a5 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/HintTextField.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/HintTextField.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. @@ -52,7 +52,7 @@ public class HintTextField extends JTextField { * @param hint the hint text */ public HintTextField(String hint) { - this(hint, false, null); + this(null, hint, false, null); } /** @@ -62,7 +62,7 @@ public class HintTextField extends JTextField { * @param required true if the field should be marked as required */ public HintTextField(String hint, boolean required) { - this(hint, required, null); + this(null, hint, required, null); } /** @@ -73,6 +73,12 @@ public class HintTextField extends JTextField { * @param verifier input verifier, or null if none needed */ public HintTextField(String hint, boolean required, InputVerifier verifier) { + this(null, hint, required, verifier); + } + + public HintTextField(String text, String hint, boolean required, InputVerifier verifier) { + super(text); + this.hint = hint; this.required = required; this.verifier = verifier; @@ -140,10 +146,17 @@ public class HintTextField extends JTextField { g2.setFont(g2.getFont().deriveFont(Font.ITALIC)); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + // center the mid-line of the hint text with the mid-line of the field Dimension size = getSize(); Insets insets = getInsets(); + FontMetrics fm = g2.getFontMetrics(); + + int fontHt = fm.getDescent() + fm.getAscent(); + int compHt = size.height - insets.top - insets.bottom; + int x = 10; // offset - int y = size.height - insets.bottom - 1; + int y = insets.top + fm.getAscent() + ((compHt - fontHt) / 2); + g2.drawString(hint, x, y); } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/PreviewTextField.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/PreviewTextField.java new file mode 100644 index 0000000000..4881a6f26e --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/textfield/PreviewTextField.java @@ -0,0 +1,140 @@ +/* ### + * 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.textfield; + +import java.awt.*; +import java.util.Objects; + +import javax.swing.InputVerifier; +import javax.swing.SwingConstants; + +import ghidra.util.HTMLUtilities; + +/** + * Abstract base class for text fields that can show a preview of a modified version of their text + * when it does not have focus. + *

+ * The tool tip of the field is updated to include the full value of the field if the preview was + * truncated during painting. Override {@link #getPreviewToolTipAdditionalText()} to control + * what text is added to the tool tip in those cases. + *

+ * NOTE: using an ending </HTML> tag in a tool tip string is not recommended as it will + * defeat PreviewTextField's updated information from being displayed to the user. + */ +public abstract class PreviewTextField extends HintTextField { + + private String origToolTip; + private boolean previewWasTruncated; + + protected PreviewTextField(String text, String hint, boolean required, InputVerifier verifier) { + super(text, hint, required, verifier); + } + + /** + * Generates a modified version of the specified string in a usage-specific manner. The + * returned string will be used as a preview of the text field's value + * (when the text field does not have focus). + * + * @param s string to base the preview value on + * @param fm FontMetrics to use when measuring the length of the string + * @param maxWidth maximum desired width of the string that should be returned by this method + * @return shortened version of parameter s + */ + protected abstract String getPreviewString(String s, FontMetrics fm, int maxWidth); + + @Override + public void paintComponent(Graphics g) { + boolean oldTrucatedFlag = previewWasTruncated; + previewWasTruncated = false; + if (isFocusOwner() || getText().isEmpty()) { + super.paintComponent(g); + } + else { + paintPreviewText((Graphics2D) g); + } + if (oldTrucatedFlag != previewWasTruncated) { + updatePreviewToolTip(); + } + } + + private void paintPreviewText(Graphics2D g2) { + + g2.setColor(getForeground()); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + FontMetrics fm = g2.getFontMetrics(); + Dimension size = getSize(); + Insets insets = getInsets(); + int fontHt = fm.getDescent() + fm.getAscent(); + int compHt = size.height - insets.top - insets.bottom; + int compW = size.width - insets.left - insets.right; + int baselineY = insets.top + fm.getAscent() + ((compHt - fontHt) / 2); + + String s = getText(); + int strW = fm.stringWidth(s); + if (strW > compW) { + previewWasTruncated = true; + s = getPreviewString(s, fm, compW); + strW = fm.stringWidth(s); + } + int x = insets.left + switch (getHorizontalAlignment()) { + case SwingConstants.LEFT -> 0; + case SwingConstants.CENTER -> compW / 2 - strW / 2; + case SwingConstants.RIGHT -> compW - strW; + default -> 0; + }; + g2.drawString(s, x, baselineY); + } + + /** + * {@return string that should be appended to the tool tip when the text field preview has been + * truncated. Defaults to the plain text of the field.} + */ + protected String getPreviewToolTipAdditionalText() { + return getText(); + } + + private void updatePreviewToolTip() { + super.setToolTipText(getPreviewToolTip()); + } + + private String getPreviewToolTip() { + String text = previewWasTruncated + ? Objects.requireNonNullElse(getPreviewToolTipAdditionalText(), "") + : ""; + if ( text.isEmpty()) { + return origToolTip; + } + String s = Objects.requireNonNullElse(origToolTip, ""); + if (!s.isEmpty()) { + s += HTMLUtilities.isHTML(s) ? "

" : "\n\n"; + } + s += text; + return s; + } + + @Override + public void setText(String text) { + super.setText(text); + updatePreviewToolTip(); + } + + @Override + public void setToolTipText(String text) { + this.origToolTip = text; + updatePreviewToolTip(); + } +} diff --git a/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/textfield/ElidingFilePathTextFieldTest.java b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/textfield/ElidingFilePathTextFieldTest.java new file mode 100644 index 0000000000..b071d515f6 --- /dev/null +++ b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/textfield/ElidingFilePathTextFieldTest.java @@ -0,0 +1,154 @@ +/* ### + * 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.textfield; + +import static org.junit.Assert.*; + +import java.awt.FontMetrics; + +import org.junit.Test; + +import docking.test.AbstractDockingTest; + +public class ElidingFilePathTextFieldTest extends AbstractDockingTest { + + @Test + public void testBasicTrunc() { + assertElide("/dir1/di.../filename", "/dir1/dir2222/filename", 20); + assertElide("dir1/dir.../filename", "dir1/dir2222/filename", 20); + + // never truncate filename + assertElide("..../filename", "/dir1/dir2222/filename", 1); + assertElide("filename", "filename", 1); + + assertElide("/", "/", 1); + assertElide("/", "/", 0); + + assertElide("", "", 1); + } + + @Test + public void testEllipseMerge() { + // 2 long path elements are removed and then merged together into a single "...." 4-dot + assertElide("/dir1/d.../directory3/filename", "/dir1/directory2/directory3/filename", 30); + assertElide("/dir1/.../directory3/filename", "/dir1/directory2/directory3/filename", 29); + assertElide("/dir1/.../d.../filename", "/dir1/directory2/directory3/filename", 23); + assertElide("/dir1/..../filename", "/dir1/directory2/directory3/filename", 22); + + // 2 long non-adjacent path elements are removed and not merged + assertElide("/dir1/.../dir3/d.../filename", "/dir1/directory2/dir3/directory4/filename", + 28); + assertElide("/dir1/.../dir3/.../filename", "/dir1/directory2/dir3/directory4/filename", 27); + assertElide("/dir1/..../filename", "/dir1/directory2/dir3/directory4/filename", 26); + } + + @Test + public void testEllipsesSpecialness() { + // test that ellipses are used as replacement sequences in the output string, but dots are + // not special or cause problems when used as input + + assertElide("/dir1/di.../filename", "/dir1/dir2.../filename", 20); + + // the long path element "directory3" is removed first and the similar "..." paths are not + // touched or merged (until required to be removed to achieve requested shortness) + assertElide("/dir1/.../.../directo.../filename", "/dir1/.../.../directory3/filename", 34); + assertElide("/dir1/.../.../d.../filename", "/dir1/.../.../directory3/filename", 27); + assertElide("/dir1/.../.../.../filename", "/dir1/.../.../directory3/filename", 26); + assertElide("/dir1/..../filename", "/dir1/.../.../directory3/filename", 25); + + // double-dots (shorter than replacement ellipses) are not treated specially + assertElide("/d.../../../filename", "/dir1/../../filename", 20); + assertElide(".../../../filename", "/dir1/../../filename", 19); + assertElide(".../../../filename", "/dir1/../../filename", 18); + assertElide("..../../filename", "/dir1/../../filename", 17); + } + + @Test + public void testSequence() { + // test progression of the shortened text. The exact locations of the shortening + // is not important, but should only change if the logic is updated. + // This also gives you a visual understanding of how strings are shortened. + + //@formatter:off + assertElide("/directory1/directory.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 64); + assertElide("/directory1/director.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 63); + assertElide("/directory1/directo.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 62); + assertElide("/directory1/direct.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 61); + assertElide("/directory1/direc.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 60); + assertElide("/directory1/dire.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 59); + assertElide("/directory1/dir.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 58); + assertElide("/directory1/di.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 57); + assertElide("/directory1/d.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 56); + assertElide("/directory1/.../directory3/dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 55); + assertElide("/directory1/.../direct.../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 54); + assertElide("/directory1/.../direc.../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 53); + assertElide("/directory1/.../dire.../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 52); + assertElide("/directory1/.../dir.../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 51); + assertElide("/directory1/.../di.../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 50); + assertElide("/directory1/.../d.../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 49); + assertElide("/directory1/..../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 48); + assertElide("/directory1/..../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 47); + assertElide("/directory1/..../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 46); + assertElide("/directory1/..../dir4/longdirectory5/filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 45); + assertElide("/directory1/..../dir4/longdirect.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 44); + assertElide("/directory1/..../dir4/longdirec.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 43); + assertElide("/directory1/..../dir4/longdire.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 42); + assertElide("/directory1/..../dir4/longdir.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 41); + assertElide("/directory1/..../dir4/longdi.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 40); + assertElide("/directory1/..../dir4/longd.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 39); + assertElide("/directory1/..../dir4/long.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 38); + assertElide("/directory1/..../dir4/lon.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 37); + assertElide("/directory1/..../dir4/lo.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 36); + assertElide("/directory1/..../dir4/l.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 35); + assertElide("/directory1/..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 34); + assertElide("/direct.../..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 33); + assertElide("/direc.../..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 32); + assertElide("/dire.../..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 31); + assertElide("/dir.../..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 30); + assertElide("/di.../..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 29); + assertElide("/d.../..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 28); + assertElide("..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 27); + assertElide("..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 26); + assertElide("..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 25); + assertElide("..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 24); + assertElide("..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 23); + assertElide("..../dir4/.../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 22); + assertElide("..../filename", "/directory1/directoryTwo/directory3/dir4/longdirectory5/filename", 21); + //@formatter:on + } + + static class TestElidingFilePathTextField extends ElidingFilePathTextField { + + @Override + protected boolean isShortEnough(String s, FontMetrics fm, int maxWidth) { + // conflate string length (chars) with rendered string width (pixels) to make this + // testable without needing an actual font / fontmetrics and to startup swing. + return s.length() <= maxWidth; + } + + @Override + public String getPreviewString(String s, FontMetrics fm, int maxWidth) { + // republish this method as public + return super.getPreviewString(s, fm, maxWidth); + } + + } + + private static void assertElide(String expected, String orig, int len) { + TestElidingFilePathTextField tf = new TestElidingFilePathTextField(); + assertEquals(expected, tf.getPreviewString(orig, null, len)); + } +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectInfoDialog.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectInfoDialog.java index 333af1f241..80dd3d493f 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectInfoDialog.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectInfoDialog.java @@ -30,6 +30,7 @@ import docking.widgets.OptionDialog; import docking.widgets.button.GButton; import docking.widgets.label.GDLabel; import docking.widgets.label.GLabel; +import docking.widgets.textfield.ElidingFilePathTextField; import docking.wizard.WizardDialog; import ghidra.app.util.GenericHelpTopics; import ghidra.framework.client.*; @@ -66,7 +67,7 @@ public class ProjectInfoDialog extends DialogComponentProvider { private JLabel userAccessLabel; private JButton changeConvertButton; private JButton convertStorageButton; - private JLabel projectDirLabel; + private JTextField projectDirField; private JLabel serverLabel; private JLabel portLabel; private JLabel repNameLabel; @@ -135,9 +136,10 @@ public class ProjectInfoDialog extends DialogComponentProvider { dirLabel.setToolTipText("Directory where your project files reside."); dirLabel.getAccessibleContext().setAccessibleName("Directory"); infoPanel.add(dirLabel); - projectDirLabel = new GDLabel(dir.getAbsolutePath()); - projectDirLabel.getAccessibleContext().setAccessibleName("Project Directory"); - infoPanel.add(projectDirLabel); + projectDirField = new ElidingFilePathTextField(dir.getAbsolutePath()); + projectDirField.setEditable(false); + projectDirField.getAccessibleContext().setAccessibleName("Project Directory"); + infoPanel.add(projectDirField); infoPanel.add(new GLabel("Project Storage Type:", SwingConstants.RIGHT)); Class fsClass = project.getProjectData().getLocalStorageClass(); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/wizard/project/SelectProjectPanel.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/wizard/project/SelectProjectPanel.java index 32d9edb9d9..08487c03ff 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/wizard/project/SelectProjectPanel.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/wizard/project/SelectProjectPanel.java @@ -26,6 +26,7 @@ import docking.widgets.button.BrowseButton; import docking.widgets.filechooser.GhidraFileChooser; import docking.widgets.filechooser.GhidraFileChooserMode; import docking.widgets.label.GDLabel; +import docking.widgets.textfield.ElidingFilePathTextField; import ghidra.framework.GenericRunInfo; import ghidra.framework.model.ProjectLocator; import ghidra.framework.preferences.Preferences; @@ -46,7 +47,7 @@ public class SelectProjectPanel extends JPanel { private static String PROJECT_EXTENSION = ProjectLocator.getProjectExtension().substring(1); private JTextField projectNameField; - private JTextField directoryField; + private ElidingFilePathTextField directoryField; private JButton browseButton; private Callback statusChangedCallback; @@ -98,7 +99,8 @@ public class SelectProjectPanel extends JPanel { private JPanel createDirectoryPanel(DocumentListener listener) { JPanel panel = new JPanel(new BorderLayout()); - directoryField = new JTextField(10); + directoryField = new ElidingFilePathTextField(); + directoryField.setColumns(10); directoryField.getDocument().addDocumentListener(listener); directoryField.setName("Project Directory");