GP-5252 - Added a 'Jump to Offset' action to the Structure Editor

This commit is contained in:
dragonmacher
2026-07-24 19:06:06 -04:00
parent ad8f879a86
commit dbb54ed92a
23 changed files with 457 additions and 184 deletions
@@ -51,7 +51,7 @@ public class HexBigIntegerTableCellEditor extends AbstractCellEditor implements
input.getComponent().setBorder(UIManager.getBorder("Table.focusCellHighlightBorder"));
input.setMinValue(null); // allow negative numbers
input.setFormat(IntegerFormat.HEX);
input.setUseNumberPrefix(false);
input.setAutoSwitchMode(false);
input.setShowNumberMode(true);
input.setHorizontalAlignment(SwingConstants.RIGHT);
@@ -17,16 +17,18 @@ package ghidra.features.bsim.gui.search.dialog;
import java.awt.BorderLayout;
import java.awt.Component;
import java.math.BigInteger;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import javax.swing.*;
import docking.DialogComponentProvider;
import docking.DockingWindowManager;
import docking.widgets.EmptyBorderButton;
import docking.widgets.textfield.IntegerTextField;
import docking.widgets.numberformat.IntegerFormatter;
import docking.widgets.numberformat.IntegerFormatterFactory;
import docking.widgets.textfield.GFormattedTextField;
import generic.theme.GIcon;
import ghidra.app.services.GoToService;
import ghidra.features.bsim.gui.BSimSearchPlugin;
@@ -49,7 +51,7 @@ public class BSimSearchDialog extends AbstractBSimSearchDialog {
private BSimFilterPanel filterPanel;
// Query Settings
private IntegerTextField maxResultsField;
private GFormattedTextField maxResultsField;
public BSimSearchDialog(PluginTool tool, BSimSearchService service,
BSimServerManager serverManager, Set<FunctionSymbol> functions) {
@@ -124,26 +126,34 @@ public class BSimSearchDialog extends AbstractBSimSearchDialog {
protected JPanel buildOptionsPanel() {
JPanel panel = super.buildOptionsPanel();
maxResultsField = new IntegerTextField(10);
maxResultsField.setValue(100);
maxResultsField.setMinValue(BigInteger.ONE);
maxResultsField.setUseNumberPrefix(false);
maxResultsField.setShowNumberMode(false);
JComponent maxResultsComponent = maxResultsField.getComponent();
IntegerFormatter formatter = new OneOrMoreIntegerFormatter();
IntegerFormatterFactory factory = new IntegerFormatterFactory(formatter, false);
maxResultsField = new GFormattedTextField(factory, 100);
JLabel maxLabel = new JLabel("Max Matches Per Function:");
maxLabel.setLabelFor(maxResultsComponent);
maxLabel.setLabelFor(maxResultsField);
panel.add(maxLabel);
panel.add(maxResultsComponent);
panel.add(maxResultsField);
return panel;
}
private class OneOrMoreIntegerFormatter extends IntegerFormatter {
@Override
protected Predicate<Number> createIsValidNumberPredicate() {
return n -> {
int i = n.intValue();
return i > 0;
};
}
}
protected BSimSearchSettings getSearchSettings() {
double similarity = similarityField.getValue();
double confidence = confidenceField.getValue();
int maxResults = maxResultsField.getIntValue();
int maxResults = (Integer) maxResultsField.getValue();
BSimFilterSet set = filterPanel.getFilterSet();
return new BSimSearchSettings(similarity, confidence, maxResults, set);
}
@@ -1215,6 +1215,16 @@
start the search from there.
</P>
</BLOCKQUOTE>
<H2><A name="Structure_Editor_Go_To_Offset"></A> Go To Offset</H2>
<BLOCKQUOTE>
<P>
<B>Go to Offset</B> is available from the popup menu. It allows you to jump to the row with
the given offset.
</P>
</BLOCKQUOTE>
<H2><A name="Structure_Editor_Show_Component_Path"></A> Showing a Component's Data Type
@@ -141,7 +141,7 @@ abstract public class CompositeEditorModel<T extends Composite> extends Composit
}
if (dataType.isDeleted()) {
// This can occur when mayny events get lumped together and a change event triggers
// This can occur when many events get lumped together and a change event triggers
// a delayed reload prior to datatype removal and its event
if (dataType == originalComposite) {
// Re-route to dataTypeRemoved callback after restoring listener.
@@ -1480,12 +1480,4 @@ abstract public class CompositeEditorModel<T extends Composite> extends Composit
return (viewComposite instanceof Structure) || (viewComposite instanceof Union);
}
/**
* Get the composite edtor's datatype manager
* @return composite edtor's datatype manager
*/
public CompositeViewerDataTypeManager<T> getViewDataTypeManager() {
return viewDTM;
}
}
@@ -952,61 +952,7 @@ public abstract class CompositeEditorPanel<T extends Composite, M extends Compos
}
}
void goToNextDefinedRow(boolean forward) {
Integer nextRow = findNextDefinedRow(forward);
if (nextRow == null) {
getToolkit().beep();
}
else {
goToRow(nextRow);
}
}
private Integer findNextDefinedRow(boolean forward) {
int currentRow = Math.max(0, model.getRow());
DtcMatcher isUndefined = dtc -> isUndefined(dtc);
int undefinedRow = findNextMatchingDtc(currentRow, forward, isUndefined);
int startRow = undefinedRow + (forward ? 1 : -1);
int n = model.getRowCount();
if (startRow >= n) {
return null;
}
DtcMatcher isDefined = dtc -> !isUndefined(dtc);
return findNextMatchingDtc(startRow, forward, isDefined);
}
private int findNextMatchingDtc(int row, boolean forward, DtcMatcher matcher) {
int start = row;
int end = forward ? model.getRowCount() : -1;
int direction = forward ? 1 : -1;
for (int i = start; i != end; i += direction) {
DataTypeComponent dtc = model.getComponent(i);
if (matcher.matches(dtc)) {
return i;
}
}
return -1;
}
// just a nicer predicate
private interface DtcMatcher {
public boolean matches(DataTypeComponent dtc);
}
private boolean isUndefined(DataTypeComponent dtc) {
if (dtc == null) {
return true;
}
DataType dt = dtc.getDataType();
return Undefined.isUndefined(dt);
}
private void goToRow(int row) {
protected void goToRow(int row) {
table.getSelectionModel().setSelectionInterval(row, row);
Rectangle cellRect = table.getCellRect(row, 0, true);
table.scrollRectToVisible(cellRect);
@@ -1601,4 +1547,5 @@ public abstract class CompositeEditorPanel<T extends Composite, M extends Compos
}
}
@@ -225,10 +225,6 @@ public abstract class CompositeEditorProvider<T extends Composite, M extends Com
}
}
public void goToNextDefinedRow(boolean forward) {
editorPanel.goToNextDefinedRow(forward);
}
@Override
public HelpLocation getHelpLocation() {
return new HelpLocation(getHelpTopic(), getHelpName());
@@ -393,4 +389,5 @@ public abstract class CompositeEditorProvider<T extends Composite, M extends Com
protected void closeDependentEditors() {
// do nothing by default
}
}
@@ -1240,4 +1240,11 @@ abstract class CompositeViewerModel<T extends Composite> extends AbstractTableMo
return viewComposite.isPackingEnabled();
}
/**
* Get the composite edtor's datatype manager
* @return composite edtor's datatype manager
*/
public CompositeViewerDataTypeManager<T> getViewDataTypeManager() {
return viewDTM;
}
}
@@ -0,0 +1,62 @@
/* ###
* 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 ghidra.app.plugin.core.compositeeditor;
import docking.ActionContext;
import docking.action.MenuData;
import docking.widgets.dialogs.NumberInputDialog;
import docking.widgets.textfield.integer.IntegerFormat;
import ghidra.util.HelpLocation;
public class JumpToOffsetAction extends CompositeEditorTableAction {
protected JumpToOffsetAction(StructureEditorProvider provider) {
super(provider, "Jump to Offset");
MenuData data = new MenuData(new String[] { "Jump to Offset" });
data.setMenuGroup(BASIC_ACTION_GROUP + "_Jump");
data.setMenuSubGroup("3");
setPopupMenuData(data);
setHelpLocation(
new HelpLocation(provider.getHelpTopic(), "Structure_Editor_Go_To_Offset"));
}
@Override
public void actionPerformed(ActionContext context) {
NumberInputDialog dialog = new NumberInputDialog("Offset", 0, 0, Integer.MAX_VALUE);
dialog.setHelpLocation(getHelpLocation());
dialog.setTitle("Enter Offset");
dialog.setPrompt("Enter offset:");
// make the 0x optional; users will have to manually change modes
dialog.setAutoSwitchMode(false);
boolean isHex = model.isShowingNumbersInHex();
if (isHex) {
dialog.setMode(IntegerFormat.HEX);
dialog.setInputAsText(""); // have no initial value
dialog.setDefaultMessage("");
}
if (!dialog.show()) {
return; // cancelled
}
int offset = dialog.getValue();
((StructureEditorProvider) provider).goToOffset(offset);
}
}
@@ -27,13 +27,14 @@ public class NextPrevDefinedComponentAction extends CompositeEditorTableAction {
private boolean forward;
public NextPrevDefinedComponentAction(CompositeEditorProvider<?, ?> provider, boolean forward) {
public NextPrevDefinedComponentAction(StructureEditorProvider provider, boolean forward) {
super(provider, getName(forward));
this.forward = forward;
MenuData data = new MenuData(new String[] { getName(forward) });
data.setMenuGroup(BASIC_ACTION_GROUP + "_2"); // put below the basic action group
data.setMenuGroup(BASIC_ACTION_GROUP + "_Jump"); // put below the basic action group
data.setMenuSubGroup("1");
setPopupMenuData(data);
setKeyBindingData(new KeyBindingData(forward ? "Control Down" : "Control Up"));
@@ -44,7 +45,7 @@ public class NextPrevDefinedComponentAction extends CompositeEditorTableAction {
@Override
public void actionPerformed(ActionContext context) {
provider.goToNextDefinedRow(forward);
((StructureEditorProvider) provider).goToNextDefinedRow(forward);
}
private static String getName(boolean forward) {
@@ -31,7 +31,8 @@ import docking.widgets.table.GTableHeaderRenderer;
import ghidra.program.model.data.*;
import ghidra.program.model.lang.InsufficientBytesException;
import ghidra.util.Msg;
import ghidra.util.exception.*;
import ghidra.util.exception.CancelledException;
import ghidra.util.exception.UsrException;
import ghidra.util.task.TaskLauncher;
import ghidra.util.task.TaskMonitor;
@@ -201,6 +202,15 @@ class StructureEditorModel extends CompEditorModel<Structure> {
return viewComposite == null ? 0 : viewComposite.getNumComponents();
}
public int getRowForOffset(int offset) {
DataTypeComponent dtc = viewComposite.getComponentContaining(offset);
if (dtc != null) {
return dtc.getOrdinal();
}
return 0;
}
@Override
protected boolean isSizeEditable() {
return !isPackingEnabled();
@@ -18,8 +18,7 @@ package ghidra.app.plugin.core.compositeeditor;
import java.awt.Component;
import docking.DockingWindowManager;
import ghidra.program.model.data.DataTypeComponent;
import ghidra.program.model.data.Structure;
import ghidra.program.model.data.*;
/**
* Editor panel for Union datatype
@@ -53,4 +52,66 @@ public class StructureEditorPanel extends CompEditorPanel<Structure, StructureEd
editorModel.notifyCompositeChanged();
editorModel.setSelection(new int[] { ordinal, ordinal });
}
void goToOffset(int offset) {
int row = model.getRowForOffset(offset);
if (row >= 0) {
goToRow(row);
}
}
void goToNextDefinedRow(boolean forward) {
Integer nextRow = findNextDefinedRow(forward);
if (nextRow == null) {
getToolkit().beep();
}
else {
goToRow(nextRow);
}
}
private Integer findNextDefinedRow(boolean forward) {
int currentRow = Math.max(0, model.getRow());
DtcMatcher isUndefined = dtc -> isUndefined(dtc);
int undefinedRow = findNextMatchingDtc(currentRow, forward, isUndefined);
int startRow = undefinedRow + (forward ? 1 : -1);
int n = model.getRowCount();
if (startRow >= n) {
return null;
}
DtcMatcher isDefined = dtc -> !isUndefined(dtc);
return findNextMatchingDtc(startRow, forward, isDefined);
}
private int findNextMatchingDtc(int row, boolean forward, DtcMatcher matcher) {
int start = row;
int end = forward ? model.getRowCount() : -1;
int direction = forward ? 1 : -1;
for (int i = start; i != end; i += direction) {
DataTypeComponent dtc = model.getComponent(i);
if (matcher.matches(dtc)) {
return i;
}
}
return -1;
}
// just a nicer predicate
private interface DtcMatcher {
public boolean matches(DataTypeComponent dtc);
}
private boolean isUndefined(DataTypeComponent dtc) {
if (dtc == null) {
return true;
}
DataType dt = dtc.getDataType();
return Undefined.isUndefined(dt);
}
}
@@ -87,6 +87,7 @@ public class StructureEditorProvider
new ShowDataTypeInTreeAction(this),
new NextPrevDefinedComponentAction(this, true),
new NextPrevDefinedComponentAction(this, false),
new JumpToOffsetAction(this),
// new ViewBitFieldAction(this)
};
@@ -165,4 +166,14 @@ public class StructureEditorProvider
}
return null;
}
public void goToNextDefinedRow(boolean forward) {
((StructureEditorPanel) editorPanel).goToNextDefinedRow(forward);
}
public void goToOffset(int offset) {
((StructureEditorPanel) editorPanel).goToOffset(offset);
}
}
@@ -477,14 +477,14 @@ class AddBlockDialog extends DialogComponentProvider implements ChangeListener {
schemeDestByteCountField = new IntegerTextField(4, 1);
schemeDestByteCountField.setMinValue(BigInteger.ZERO);
schemeDestByteCountField.setUseNumberPrefix(false);
schemeDestByteCountField.setAutoSwitchMode(false);
schemeDestByteCountField.setFormat(IntegerFormat.DEC);
schemeDestByteCountField.addChangeListener(ev -> schemeDestByteCountChanged());
schemeDestByteCountField.setAccessibleName("Mapping Ratio: Destination Size");
schemeSrcByteCountField = new IntegerTextField(4, 1);
schemeSrcByteCountField.setMinValue(BigInteger.ZERO);
schemeSrcByteCountField.setUseNumberPrefix(false);
schemeSrcByteCountField.setAutoSwitchMode(false);
schemeSrcByteCountField.setFormat(IntegerFormat.DEC);
schemeSrcByteCountField.addChangeListener(ev -> schemeSrcByteCountChanged());
schemeSrcByteCountField.setAccessibleName("Mapping Ratio: Source Size");
@@ -24,6 +24,7 @@ import docking.DialogComponentProvider;
import docking.DockingWindowManager;
import docking.widgets.label.GDLabel;
import docking.widgets.textfield.IntegerTextField;
import docking.widgets.textfield.integer.AbstractIntegerTextField;
import docking.widgets.textfield.integer.IntegerFormat;
import ghidra.util.Swing;
@@ -64,9 +65,7 @@ public abstract class AbstractNumberInputDialog extends DialogComponentProvider
* @param showAsHex if true, the initial value will be displayed as hex
*/
public AbstractNumberInputDialog(String title, String prompt, BigInteger initialValue,
BigInteger min,
BigInteger max,
boolean showAsHex) {
BigInteger min, BigInteger max, boolean showAsHex) {
super(title, true, true, true, false);
this.min = min;
@@ -204,10 +203,12 @@ public abstract class AbstractNumberInputDialog extends DialogComponentProvider
private void selectAndFocusText() {
Swing.runLater(() -> {
numberInputField.requestFocus();
numberInputField.selectAll();
});
if (numberInputField.isSelectTextOnFocusGainedEnabled()) {
numberInputField.requestFocus();
numberInputField.selectAll();
}
});
}
/**
@@ -228,6 +229,41 @@ public abstract class AbstractNumberInputDialog extends DialogComponentProvider
numberInputField.setValue(value);
}
/**
* Sets the fields value as text.
* @param text the text
*/
public void setInputAsText(String text) {
numberInputField.setText(text);
}
/**
* Sets the input mode for the field in this dialog.
* @param mode the mode
* @see AbstractIntegerTextField#setFormat(IntegerFormat)
*/
public void setMode(IntegerFormat mode) {
numberInputField.setFormat(mode);
}
/**
* Sets whether this text field will switch the input mode format when typing a matching prefix.
*
* @param autoSwitch true to auto-switch; false requires user to change modes manually
* @see AbstractIntegerTextField#setAutoSwitchMode(boolean)
*/
public void setAutoSwitchMode(boolean autoSwitch) {
numberInputField.setAutoSwitchMode(autoSwitch);
}
/**
* Sets the label text of this dialog. The default value is "Enter number of items".
* @param text the text
*/
public void setPrompt(String text) {
label.setText(text);
}
/**
* Sets the default message to be displayed when valid values are in the text fields.
* @param defaultMessage the message to be displayed when valid values are in the text fields.
@@ -96,5 +96,4 @@ public class NumberInputDialog extends AbstractNumberInputDialog {
public int getValue() {
return getIntValue();
}
}
@@ -17,6 +17,7 @@ package docking.widgets.numberformat;
import java.awt.Toolkit;
import java.text.*;
import java.util.function.Predicate;
import javax.swing.text.*;
@@ -36,18 +37,21 @@ public class IntegerFormatter extends NumberFormatter {
setAllowsInvalid(true);
}
protected Predicate<Number> createIsValidNumberPredicate() {
return n -> true;
}
@Override
protected DocumentFilter getDocumentFilter() {
if (myDocumentFilter == null) {
myDocumentFilter = createDocumentFilter();
}
return myDocumentFilter;
}
protected DocumentFilter createDocumentFilter() {
return new PosiviteValueIntegerDocumentFilterWrapper(getFormat(),
getOriginalDocumentFilter());
getOriginalDocumentFilter(), createIsValidNumberPredicate());
}
protected DocumentFilter getOriginalDocumentFilter() {
@@ -58,10 +62,17 @@ public class IntegerFormatter extends NumberFormatter {
protected final DocumentFilter wrappedFilter;
protected final Format format;
protected final Predicate<Number> isValidNumber;
PosiviteValueIntegerDocumentFilterWrapper(Format format, DocumentFilter wrappedFilter) {
this(format, wrappedFilter, n -> true);
}
PosiviteValueIntegerDocumentFilterWrapper(Format format, DocumentFilter wrappedFilter,
Predicate<Number> isValidNumber) {
this.format = format;
this.wrappedFilter = wrappedFilter;
this.isValidNumber = isValidNumber;
}
@Override
@@ -132,6 +143,10 @@ public class IntegerFormatter extends NumberFormatter {
return false;
}
if (!isValidNumber.test(number)) {
return false;
}
Long longValue = number.longValue();
if (longValue.compareTo(0L) < 0) {
return false; // no negatives
@@ -44,17 +44,14 @@ import docking.widgets.textfield.integer.IntegerFormat;
* equal to this value.</LI>
* <LI>Min value - This value must be generally be negative and will restrict the input to values
* greater than or equal to this value. As a special case, the min value can be set to 1.</LI>
* <LI>Use number prefix - If this mode is on, then non-decimal values must be typed with its
* prefix(i.e., 0x for hex). When requiring non-decimal prefix, the field is permitted to auto
* <LI>Auto-switch mode - When requiring non-decimal prefix, the field is permitted to auto
* switch formats based on the prefix (or lack thereof). When the use prefix is off, the only way
* to switch formats is to use the ctrl-M action.</LI>
* <LI>Show the number mode as hint text - If showing number mode is on, the format short name
* is displayed lightly in the bottom right portion of the text field.
* See {@link #setShowNumberMode(boolean)}</LI>
* </UL>
*
*/
public class IntegerTextField extends AbstractIntegerTextField {
private IntegerFormat hex;
private IntegerFormat decimal;
@@ -137,11 +134,11 @@ public class IntegerTextField extends AbstractIntegerTextField {
* mode and the typed text will be interpreted appropriately for the mode.
*
* @param usePrefix true to use the 0x convention for hex.
* @deprecated use {@link #setUseNumberPrefix(boolean)} instead
* @deprecated use {@link #setAutoSwitchMode(boolean)} instead
*/
@Deprecated(forRemoval = true, since = "12.2")
public void setAllowsHexPrefix(boolean usePrefix) {
setUseNumberPrefix(usePrefix);
setAutoSwitchMode(usePrefix);
}
/**
@@ -25,6 +25,9 @@ import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.text.*;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
/**
* Base class for IntegerTextFields that allow entering integer values based on some
* integer format (i.e., hex, decimal, unsigned hex, binary, etc.). This field does input
@@ -38,7 +41,7 @@ public class AbstractIntegerTextField {
protected IntegerFormat currentFormat;
private BigInteger minValue;
private BigInteger maxValue;
private boolean usePrefix = true;
private boolean autoSwitch = true;
/**
* Creates a new IntegerTextField with the specified number of columns and initial value
@@ -53,7 +56,7 @@ public class AbstractIntegerTextField {
allFormats = Arrays.asList(formats);
currentFormat = allFormats.get(0);
textField = new MultiFormatTextField(columns, allFormats, m -> setFormat(m));
textField = new CustomHintMultiFormatTextField(columns);
AbstractDocument document = (AbstractDocument) textField.getDocument();
document.setDocumentFilter(new HexDecimalDocumentFilter());
@@ -61,6 +64,14 @@ public class AbstractIntegerTextField {
textField.addTextChangedCallback(this::valueChanged);
}
/**
* Sets the accessible name for the component of this input field.
* @param name the accessible name for this field
*/
public void setAccessibleName(String name) {
textField.getAccessibleContext().setAccessibleName(name);
}
/**
* Adds a change listener that will be notified whenever the value changes.
*
@@ -70,14 +81,6 @@ public class AbstractIntegerTextField {
listeners.add(listener);
}
/**
* Sets the accessible name for the component of this input field.
* @param name the accessible name for this field
*/
public void setAccessibleName(String name) {
textField.getAccessibleContext().setAccessibleName(name);
}
/**
* Removes the changes listener.
*
@@ -87,6 +90,17 @@ public class AbstractIntegerTextField {
listeners.remove(listener);
}
/**
* {@return true if the text field will select the text when it initially gains focus.}
*/
public boolean isSelectTextOnFocusGainedEnabled() {
Object value = textField.getClientProperty("JTextField.selectAllOnFocusPolicy");
if (value == null) {
return false;
}
return Strings.CI.containsAny(value.toString(), "once", "always");
}
/**
* Returns the current value of the field or null if the field has no current value.
*
@@ -94,7 +108,11 @@ public class AbstractIntegerTextField {
*/
public BigInteger getValue() {
String text = textField.getText();
return parse(text, currentFormat);
BigInteger value = parse(text, currentFormat);
if (isInBounds(value)) {
return value;
}
return null;
}
/**
@@ -198,9 +216,14 @@ public class AbstractIntegerTextField {
}
private String addPrefix(String text) {
// 'autoSwitch' requires a prefix. When not using auto-switch, do not add a prefix if the
// user has not added one.
boolean usePrefix = autoSwitch;
if (!usePrefix) {
return text;
}
String prefix = currentFormat.getPrefix();
if (prefix.isBlank()) {
return text;
@@ -328,6 +351,18 @@ public class AbstractIntegerTextField {
textField.selectAll();
}
/**
* Clears the current selection and places the caret at the end of the text.
*/
public void clearSelection() {
String text = textField.getText();
int end = 0;
if (!StringUtils.isBlank(text)) {
end = text.length();
}
textField.setCaretPosition(end);
}
/**
* Sets the horizontal alignment of the JTextField
*
@@ -338,16 +373,17 @@ public class AbstractIntegerTextField {
}
/**
* Sets whether or not that non-decimal formats require using a prefix (i.e., "0x" for hex).
* Generally, using a prefix is preferred as it allows the mode to auto-switch as the user
* types (or not types) a prefix. If the prefix is not used, the only way to change input
* formats is to use the built-in cntr-M action.
* @param usePrefix true to require a prefix, false to not require a prefix
* Sets whether this text field will switch the input mode format when typing a matching prefix.
* For example, assuming DEC and HEX modes are available, while in DEC mode, typing {@code 0x}
* will switch the mode format to HEX.
* <P>
* Auto-switching is on by default.
*
* @param newAutoSwitch true to auto-switch; false requires user to change modes manually
*/
public void setUseNumberPrefix(boolean usePrefix) {
BigInteger value = getValue();
this.usePrefix = usePrefix;
setValue(value);
public void setAutoSwitchMode(boolean newAutoSwitch) {
this.autoSwitch = newAutoSwitch;
textField.repaint();
}
/**
@@ -358,13 +394,35 @@ public class AbstractIntegerTextField {
return new ArrayList<>(allFormats);
}
/**
* Sets the minimum value. The given value must be less than or equal to 0.
* @param minValue the value
*/
protected void setMinValue(BigInteger minValue) {
if (minValue != null) {
if (minValue.compareTo(BigInteger.ZERO) > 0) {
throw new IllegalArgumentException("Min value must be <= 0");
}
}
BigInteger value = getValue();
this.minValue = minValue;
setValue(value);
}
/**
* Sets the maximum value. The given value must be greater than 0.
* @param maxValue the value
*/
protected void setMaxValue(BigInteger maxValue) {
if (maxValue != null) {
if (maxValue.compareTo(BigInteger.ZERO) <= 0) {
throw new IllegalArgumentException("Max value must be > 0");
}
}
BigInteger value = getValue();
this.maxValue = maxValue;
setValue(value);
@@ -381,6 +439,9 @@ public class AbstractIntegerTextField {
}
protected boolean isInBounds(BigInteger value) {
if (value == null) {
return false;
}
if (minValue != null && minValue.compareTo(value) > 0) {
return false;
}
@@ -391,21 +452,41 @@ public class AbstractIntegerTextField {
if (text.equals("0") || text.equals("-0")) {
return BigInteger.ZERO;
}
String prefix = format.getPrefix();
if (usePrefix && !prefix.isBlank()) {
if (text.startsWith(prefix)) {
text = text.substring(prefix.length());
}
else if (text.startsWith("-" + prefix)) {
text = "-" + text.substring(prefix.length() + 1);
}
else {
return null;
}
if (prefix.isBlank()) {
return format.parse(text);
}
// auto-switching requires a prefix so we know when we should switch formats
boolean requiresPrefix = autoSwitch;
boolean hasPrefix = hasPrefix(text, prefix);
if (requiresPrefix && !hasPrefix) {
return null;
}
text = text.replaceFirst(prefix, "");
return format.parse(text);
}
private boolean hasPrefix(String text, String prefix) {
return text.startsWith(prefix) || text.startsWith("-" + prefix);
}
private boolean hasFormatPrefix(String text, IntegerFormat format) {
if (text.startsWith("-")) {
if (!allowsNegative()) {
return false;
}
if (text.length() == 1) {
return true;
}
text = text.substring(1);
}
return format.getPrefix().startsWith(text);
}
private boolean isValidPrefix(String text, IntegerFormat format) {
if (text.startsWith("-")) {
if (!allowsNegative()) {
@@ -416,12 +497,13 @@ public class AbstractIntegerTextField {
}
text = text.substring(1);
}
if (!usePrefix) {
return false;
}
return usePrefix && format.getPrefix().startsWith(text);
return format.getPrefix().startsWith(text);
}
//=================================================================================================
// Inner Classes
//=================================================================================================
/**
* DocumentFilter that prevents users from entering invalid data into the field.
*/
@@ -473,8 +555,9 @@ public class AbstractIntegerTextField {
if (text.isEmpty()) {
return true;
}
if (isValidPrefix(text, currentFormat)) {
return true;
return true; // just a prefix and is valid
}
BigInteger value = parse(text, currentFormat);
@@ -482,8 +565,8 @@ public class AbstractIntegerTextField {
return isInBounds(value);
}
if (usePrefix) {
// only allow auto switching if using number prefix
// only allow auto switching if using number prefix
if (autoSwitch) {
return autoSwitchFormat(text);
}
return false;
@@ -491,11 +574,12 @@ public class AbstractIntegerTextField {
private boolean autoSwitchFormat(String text) {
for (IntegerFormat format : allFormats) {
if (isValidPrefix(text, format)) {
if (hasFormatPrefix(text, format)) {
currentFormat = format;
textField.setFormat(format);
return true;
}
BigInteger value = parse(text, format);
if (value != null && isInBounds(value)) {
currentFormat = format;
@@ -515,4 +599,30 @@ public class AbstractIntegerTextField {
}
}
private class CustomHintMultiFormatTextField extends MultiFormatTextField {
public CustomHintMultiFormatTextField(int columns) {
super(columns, allFormats, m -> AbstractIntegerTextField.this.setFormat(m));
}
@Override
protected String getHintToolTipText() {
String superText = super.getHintToolTipText();
if (autoSwitch) {
return superText;
}
return superText + "<br><br><i>*Auto-switch mode disabled</i>";
}
@Override
protected String getHint() {
String superHint = super.getHint();
if (autoSwitch) {
return superHint;
}
return superHint + "*";
}
}
}
@@ -39,17 +39,18 @@ import utility.function.Callback;
public class MultiFormatTextField extends JTextField {
private static final String FONT_ID = "font.input.hint";
private int hintWidth;
private boolean showFormatHint = true;
private List<IntegerFormat> formats;
private int currentFormatIndex;
private String toolTipAppendix;
public MultiFormatTextField(int columns, List<IntegerFormat> formats,
Consumer<IntegerFormat> formatChangeConsumer) {
super(columns);
this.formats = formats;
updateFormatNameWidth();
addKeyListener(new KeyAdapter() {
@Override
@@ -58,7 +59,7 @@ public class MultiFormatTextField extends JTextField {
if (++currentFormatIndex >= formats.size()) {
currentFormatIndex = 0;
}
updateFormatNameWidth();
formatChangeConsumer.accept(formats.get(currentFormatIndex));
repaint();
}
@@ -67,7 +68,6 @@ public class MultiFormatTextField extends JTextField {
// make sure tooltips will be activated
ToolTipManager.sharedInstance().registerComponent(this);
}
/**
@@ -96,30 +96,41 @@ public class MultiFormatTextField extends JTextField {
});
}
private void updateFormatNameWidth() {
FontMetrics fontMetrics = getFontMetrics(Gui.getFont(FONT_ID));
hintWidth = fontMetrics.stringWidth(formats.get(currentFormatIndex).getName());
protected String getHint() {
return formats.get(currentFormatIndex).getName();
}
public void setToolTipAppendix(String appendix) {
this.toolTipAppendix = appendix;
}
@Override
public String getToolTipText(MouseEvent event) {
int hintStart = getBounds().width - hintWidth;
String hint = getHint();
FontMetrics fontMetrics = getFontMetrics(Gui.getFont(FONT_ID));
int hintWidth = fontMetrics.stringWidth(hint);
int hintStart = getBounds().width - hintWidth;
if (event.getX() > hintStart && formats.size() > 1) {
String key = DockingUtils.CONTROL_KEY_NAME;
IntegerFormat format = formats.get(currentFormatIndex);
return "Enter value in %s format. Press %s-M to cycle input formats."
.formatted(format.getDescription(), key);
return getHintToolTipText();
}
return super.getToolTipText(event);
}
protected String getHintToolTipText() {
String key = DockingUtils.CONTROL_KEY_NAME;
IntegerFormat format = formats.get(currentFormatIndex);
String appendix = toolTipAppendix == null ? "" : toolTipAppendix;
return """
<html>Enter value in %s format.<br>
<i>Press %s-M to cycle input formats.</i>%s
""".formatted(format.getDescription(), key, appendix);
}
/**
* Sets the {@link IntegerFormat} that will be used to format and parse the text in this
* field.
* Sets the {@link IntegerFormat} that will be used to format and parse the text in this field.
* @param format the number format that will be used to format and parse the text in this field
*/
public void setFormat(IntegerFormat format) {
@@ -127,7 +138,6 @@ public class MultiFormatTextField extends JTextField {
if (indexOf >= 0) {
currentFormatIndex = indexOf;
}
updateFormatNameWidth();
repaint();
}
@@ -153,6 +163,10 @@ public class MultiFormatTextField extends JTextField {
g.setFont(Gui.getFont(FONT_ID));
g.setColor(Messages.HINT);
String hint = getHint();
FontMetrics fontMetrics = getFontMetrics(Gui.getFont(FONT_ID));
int hintWidth = fontMetrics.stringWidth(hint);
Dimension size = getSize();
Insets insets = getInsets();
int x;
@@ -163,8 +177,7 @@ public class MultiFormatTextField extends JTextField {
x = size.width - insets.right - hintWidth;
}
int y = size.height - insets.bottom - 1;
IntegerFormat format = formats.get(currentFormatIndex);
GraphicsUtils.drawString(this, g, format.getName(), x, y);
GraphicsUtils.drawString(this, g, hint, x, y);
g.setFont(savedFont);
}
@@ -70,7 +70,7 @@ public class IntValue extends AbstractValue<Integer> {
public JComponent getComponent() {
if (field == null) {
field = new IntegerTextField(20);
field.setUseNumberPrefix(false);
field.setAutoSwitchMode(false);
field.setShowNumberMode(false);
if (displayAsHex) {
field.setFormat(IntegerFormat.HEX);
@@ -58,7 +58,7 @@ public class LongValue extends AbstractValue<Long> {
public JComponent getComponent() {
if (field == null) {
field = new IntegerTextField(20);
field.setUseNumberPrefix(false);
field.setAutoSwitchMode(false);
field.setShowNumberMode(false);
if (displayAsHex) {
field.setFormat(IntegerFormat.HEX);
@@ -98,8 +98,8 @@ public abstract class AbstractIntegerTextFieldTest<T extends AbstractIntegerText
triggerText(textField, text);
}
protected void setUsePrefix(boolean b) {
runSwing(() -> field.setUseNumberPrefix(b));
protected void setAutoSwitchMode(boolean b) {
runSwing(() -> field.setAutoSwitchMode(b));
}
class TestChangeListener implements ChangeListener {
@@ -80,7 +80,7 @@ public class IntegerTextFieldTest extends AbstractIntegerTextFieldTest<IntegerTe
typeText("a");
assertEquals(null, getBigIntegerValue());
setUsePrefix(false);
setAutoSwitchMode(false);
typeText("abc");
assertEquals(0xabc, getValue());
}
@@ -134,12 +134,9 @@ public class IntegerTextFieldTest extends AbstractIntegerTextFieldTest<IntegerTe
assertNull(field.getValue());
}
@Test
@Test(expected = IllegalArgumentException.class)
public void testMinSetTo1() {
field.setMinValue(BigInteger.ONE);
setValue(0);
assertEquals(1, getValue());
}
@Test
@@ -219,15 +216,15 @@ public class IntegerTextFieldTest extends AbstractIntegerTextFieldTest<IntegerTe
@Test
public void testHexValueInDontRequireHexPrefixMode() {
field.setUseNumberPrefix(false);
field.setAutoSwitchMode(false);
field.setFormat(HEX);
setValue(255);
assertEquals("ff", field.getText());
}
@Test
public void testAutoModeSwitchingIsOffWhenPrefixNotUsed() {
field.setUseNumberPrefix(false);
public void testAutoSwitchingMode() {
field.setAutoSwitchMode(false);
field.setFormat(HEX);
typeText("15");
assertEquals(HEX, getFormat());
@@ -265,12 +262,12 @@ public class IntegerTextFieldTest extends AbstractIntegerTextFieldTest<IntegerTe
@Test
public void testUseHexPrefixUpdatesTextField() {
field.setUseNumberPrefix(false);
field.setAutoSwitchMode(false);
setFormat(HEX);
setValue(255);
assertEquals("ff", field.getText());
field.setUseNumberPrefix(true);
assertEquals("0xff", field.getText());
field.setAutoSwitchMode(true);
assertEquals("ff", field.getText());
}
@Test
@@ -311,21 +308,19 @@ public class IntegerTextFieldTest extends AbstractIntegerTextFieldTest<IntegerTe
}
@Test
public void testMinValueOfOneDecimalFormat() {
public void testDecimalFormat_ValueOfZero() {
setFormat(DEC);
field.setMinValue(BigInteger.ONE);
typeText("0");
assertEquals("", field.getText());
typeText("1");
assertEquals("1", field.getText());
assertEquals("0", field.getText());
assertEquals(BigInteger.ZERO, field.getValue());
}
@Test
public void testMinValueOfOneHexFormat() {
public void testHexFormat_ValueOfZero() {
setFormat(HEX);
field.setMinValue(BigInteger.ONE);
typeText("0x1");
assertEquals("0x1", field.getText());
typeText("0");
assertEquals("0", field.getText());
assertEquals(BigInteger.ZERO, field.getValue());
}
protected void setMinValue(BigInteger minValue) {