From 2ae42befb809444966a9a37a2d6e72fbb61d65ea Mon Sep 17 00:00:00 2001 From: dragonmacher <48328597+dragonmacher@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:34:30 -0500 Subject: [PATCH] GP-6076 - Find All - Added a button to the Find dialog to find all matches and show the results in a table --- Ghidra/Features/Base/certification.manifest | 1 - .../Base/data/base.icons.theme.properties | 1 - .../FrontEndPlugin/Ghidra_Front_end.htm | 2 +- .../console/ConsoleComponentProvider.java | 11 +- .../InterpreterComponentProvider.java | 13 +- .../locationreferences/LocationReference.java | 17 +- .../LocationReferencesPlugin.java | 2 +- .../LocationReferencesProvider.java | 2 +- .../LocationReferencesTableModel.java | 9 +- .../locationreferences/ReferenceUtils.java | 3 +- .../core/scalartable/ScalarSearchPlugin.java | 6 +- .../scalartable/ScalarSearchProvider.java | 2 +- .../core/table/TableComponentProvider.java | 2 +- .../plugin/core/table/TableServicePlugin.java | 14 +- .../app/services/DataTypeReference.java | 12 +- .../memsearch/gui/MemorySearchProvider.java | 2 +- .../base/quickfix/QuickFixTableProvider.java | 2 +- .../java/ghidra/util/table/GhidraTable.java | 2 + .../core/console/ConsolePluginTest.java | 172 ++++- .../DecompilerDiffViewFindAction.java | 11 +- .../data/decompiler.theme.properties | 3 +- .../app/decompiler/DecompileOptions.java | 8 + .../component/DecompilerFindDialog.java | 237 +------ .../decompiler/component/DecompilerPanel.java | 67 +- .../actions/DecompilerCursorPosition.java | 10 +- .../actions/DecompilerSearchLocation.java | 25 +- .../actions/DecompilerSearchResults.java | 245 +++++++ .../decompile/actions/DecompilerSearcher.java | 233 +++---- .../core/decompile/actions/FindAction.java | 5 + .../decompile/DecompilerFindDialogTest.java | 6 +- .../datatype/finder/DecompilerReference.java | 12 +- .../datatype/finder/VariableAccessDR.java | 10 +- .../extension/datatype/finder/VariableDR.java | 8 +- .../core/search/DecompilerTextFinder.java | 14 +- .../DecompilerTextFinderTableModel.java | 14 +- .../app/plugin/core/search/TextMatch.java | 8 +- .../Framework/Docking/certification.manifest | 1 + .../Docking/data/docking.theme.properties | 1 + .../src/main/java/docking/ComponentNode.java | 10 +- .../java/docking/ComponentPlaceholder.java | 1 + .../main/java/docking/ComponentProvider.java | 2 +- .../java/docking/help/HelpViewSearcher.java | 188 +++++- .../main/java/docking/widgets/FindDialog.java | 121 +++- .../widgets/FindDialogResultsProvider.java | 342 ++++++++++ .../java/docking/widgets/SearchLocation.java | 52 +- .../widgets/TextComponentSearcher.java | 539 --------------- .../{ => search}/FindDialogSearcher.java | 46 +- .../search/SearchLocationContext.java} | 27 +- .../search/SearchLocationContextBuilder.java} | 22 +- .../docking/widgets/search/SearchResults.java | 302 +++++++++ .../search/TextComponentSearchLocation.java | 47 ++ .../search/TextComponentSearchResults.java | 616 ++++++++++++++++++ .../widgets/search/TextComponentSearcher.java | 335 ++++++++++ .../tabbedpane/DockingTabRenderer.java | 16 +- .../table/actions/DeleteTableRowAction.java | 15 +- .../main/resources/images/table_delete.png | Bin .../java/docking/widgets/FindDialogTest.java | 28 +- .../search/TextComponentSearcherTest.java | 400 ++++++++++++ .../java/generic/concurrent/ConcurrentQ.java | 52 +- .../ghidra/util/worker/AbstractWorker.java | 36 +- .../ghidra/util/worker/PriorityWorker.java | 12 +- .../main/java/ghidra/util/worker/Worker.java | 1 - .../Gui/data/gui.palette.theme.properties | 1 + 63 files changed, 3131 insertions(+), 1273 deletions(-) create mode 100644 Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearchResults.java create mode 100644 Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialogResultsProvider.java delete mode 100644 Ghidra/Framework/Docking/src/main/java/docking/widgets/TextComponentSearcher.java rename Ghidra/Framework/Docking/src/main/java/docking/widgets/{ => search}/FindDialogSearcher.java (68%) rename Ghidra/{Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferenceContext.java => Framework/Docking/src/main/java/docking/widgets/search/SearchLocationContext.java} (85%) rename Ghidra/{Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferenceContextBuilder.java => Framework/Docking/src/main/java/docking/widgets/search/SearchLocationContextBuilder.java} (72%) create mode 100644 Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchResults.java create mode 100644 Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearchLocation.java create mode 100644 Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearchResults.java create mode 100644 Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearcher.java rename Ghidra/{Features/Base/src/main/java/ghidra/util => Framework/Docking/src/main/java/docking/widgets}/table/actions/DeleteTableRowAction.java (93%) rename Ghidra/{Features/Base => Framework/Docking}/src/main/resources/images/table_delete.png (100%) create mode 100644 Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/search/TextComponentSearcherTest.java diff --git a/Ghidra/Features/Base/certification.manifest b/Ghidra/Features/Base/certification.manifest index 3463cb92a8..646decbb0d 100644 --- a/Ghidra/Features/Base/certification.manifest +++ b/Ghidra/Features/Base/certification.manifest @@ -1054,7 +1054,6 @@ src/main/resources/images/small_minus.png||GHIDRA||||END| src/main/resources/images/small_plus.png||GHIDRA||||END| src/main/resources/images/stopNode.png||FAMFAMFAM Icons - CC 2.5|||famfamfam silk icon set|END| src/main/resources/images/sync_enabled.png||GHIDRA||||END| -src/main/resources/images/table_delete.png||FAMFAMFAM Icons - CC 2.5|||famfamfam silk icon set|END| src/main/resources/images/table_go.png||FAMFAMFAM Icons - CC 2.5|||famfamfam silk icon set|END| src/main/resources/images/table_row_delete.png||FAMFAMFAM Icons - CC 2.5|||silk|END| src/main/resources/images/tag_blue.png||FAMFAMFAM Icons - CC 2.5|||famfamfam silk icon set|END| diff --git a/Ghidra/Features/Base/data/base.icons.theme.properties b/Ghidra/Features/Base/data/base.icons.theme.properties index 85b4e71297..98e7689a3a 100644 --- a/Ghidra/Features/Base/data/base.icons.theme.properties +++ b/Ghidra/Features/Base/data/base.icons.theme.properties @@ -324,7 +324,6 @@ icon.plugin.symboltable.referencetable.provider = table_go.png icon.plugin.table.service = icon.search icon.plugin.table.service.marker = icon.base.search.marker -icon.plugin.table.delete.row = table_delete.png icon.plugin.totd.provider = help-hint.png diff --git a/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end.htm b/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end.htm index 6a3ffc3641..12b50fd46b 100644 --- a/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end.htm +++ b/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end.htm @@ -108,7 +108,7 @@
-

The data tree shows all files in the project orgnanized into folders and sub-folders. +

The data tree shows all files in the project organized into folders and sub-folders. Icons for files indicate whether they are under version control and whether you have the file firstActivationCallbacks; private FindDialog findDialog; - private TextComponentSearcher searcher; public InterpreterComponentProvider(InterpreterPanelPlugin plugin, InterpreterConnection interpreter, boolean visible) { @@ -95,8 +96,8 @@ public class InterpreterComponentProvider extends ComponentProviderAdapter private void showFindDialog() { if (findDialog == null) { JTextPane textPane = panel.getOutputTextPane(); - searcher = new TextComponentSearcher(textPane); - findDialog = new FindDialog("Find", searcher); + TextComponentSearcher searcher = new TextComponentSearcher(textPane); + findDialog = new FindDialog("Intepreter Find", searcher); } getTool().showDialog(findDialog); } @@ -154,8 +155,8 @@ public class InterpreterComponentProvider extends ComponentProviderAdapter public void clear() { panel.clear(); - if (searcher != null) { - searcher.clearHighlights(); + if (findDialog != null) { + findDialog.close(); // this will also dispose of any search highlights } } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReference.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReference.java index bd6a8bf1bd..4edc7dd3e4 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReference.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReference.java @@ -15,10 +15,11 @@ */ package ghidra.app.plugin.core.navigation.locationreferences; -import static ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext.*; +import static docking.widgets.search.SearchLocationContext.*; import java.util.Objects; +import docking.widgets.search.SearchLocationContext; import ghidra.program.model.address.Address; import ghidra.program.model.symbol.DynamicReference; import ghidra.program.model.symbol.Reference; @@ -34,7 +35,7 @@ public class LocationReference implements Comparable { private final boolean isOffcutReference; private final Address locationOfUseAddress; private final String refType; - private final LocationReferenceContext context; + private final SearchLocationContext context; private final ProgramLocation location; /** @@ -52,7 +53,7 @@ public class LocationReference implements Comparable { // represents the 'from' address for a reference; for parameters and variables of a // function, this represents the address of that variable. private LocationReference(Address address, ProgramLocation location, String refType, - LocationReferenceContext context, boolean isOffcut) { + SearchLocationContext context, boolean isOffcut) { this.locationOfUseAddress = Objects.requireNonNull(address); this.location = location; this.refType = refType == null ? "" : refType; @@ -75,15 +76,15 @@ public class LocationReference implements Comparable { } LocationReference(Address locationOfUseAddress, String context) { - this(locationOfUseAddress, null, null, LocationReferenceContext.get(context), false); + this(locationOfUseAddress, null, null, SearchLocationContext.get(context), false); } - LocationReference(Address locationOfUseAddress, LocationReferenceContext context) { - this(locationOfUseAddress, null, null, LocationReferenceContext.get(context), false); + LocationReference(Address locationOfUseAddress, SearchLocationContext context) { + this(locationOfUseAddress, null, null, SearchLocationContext.get(context), false); } LocationReference(Address locationOfUseAddress, String context, ProgramLocation location) { - this(locationOfUseAddress, location, null, LocationReferenceContext.get(context), false); + this(locationOfUseAddress, location, null, SearchLocationContext.get(context), false); } /** @@ -128,7 +129,7 @@ public class LocationReference implements Comparable { * * @return the context */ - public LocationReferenceContext getContext() { + public SearchLocationContext getContext() { return context; } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesPlugin.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesPlugin.java index 10bd4a9bd4..7c337f2eb9 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesPlugin.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesPlugin.java @@ -21,6 +21,7 @@ import java.util.function.Supplier; import docking.ActionContext; import docking.action.DockingAction; import docking.action.builder.ActionBuilder; +import docking.widgets.table.actions.DeleteTableRowAction; import ghidra.app.CorePluginPackage; import ghidra.app.context.ListingActionContext; import ghidra.app.events.ProgramClosedPluginEvent; @@ -39,7 +40,6 @@ import ghidra.program.model.symbol.Reference; import ghidra.program.util.ProgramLocation; import ghidra.util.HelpLocation; import ghidra.util.Msg; -import ghidra.util.table.actions.DeleteTableRowAction; /** * Plugin to show a list of references to the item represented by the location of the cursor. diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesProvider.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesProvider.java index 2685f1eeb1..d62fd286e5 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesProvider.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesProvider.java @@ -27,6 +27,7 @@ import docking.ActionContext; import docking.action.*; import docking.action.builder.ActionBuilder; import docking.widgets.table.GTable; +import docking.widgets.table.actions.DeleteTableRowAction; import generic.theme.GIcon; import ghidra.app.cmd.refs.RemoveReferenceCmd; import ghidra.app.nav.Navigatable; @@ -45,7 +46,6 @@ import ghidra.program.util.ProgramLocation; import ghidra.util.HelpLocation; import ghidra.util.table.GhidraTable; import ghidra.util.table.SelectionNavigationAction; -import ghidra.util.table.actions.DeleteTableRowAction; import ghidra.util.table.actions.MakeProgramSelectionAction; import ghidra.util.task.SwingUpdateManager; import resources.Icons; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesTableModel.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesTableModel.java index 5736acd5ec..f93a71f6bf 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesTableModel.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferencesTableModel.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. @@ -20,6 +20,7 @@ import java.util.*; import org.apache.commons.lang3.StringUtils; +import docking.widgets.search.SearchLocationContext; import docking.widgets.table.GTableCellRenderingData; import ghidra.docking.settings.Settings; import ghidra.framework.plugintool.ServiceProvider; @@ -176,7 +177,7 @@ class LocationReferencesTableModel extends AddressBasedTableModel callback = ref -> { - LocationReferenceContext context = ref.getContext(); + SearchLocationContext context = ref.getContext(); LocationReference locationReference = new LocationReference(ref.getAddress(), context); accumulator.add(locationReference); }; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/scalartable/ScalarSearchPlugin.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/scalartable/ScalarSearchPlugin.java index 4c1b7b9a31..347f72f1c9 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/scalartable/ScalarSearchPlugin.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/scalartable/ScalarSearchPlugin.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,6 +23,7 @@ import java.util.*; import docking.action.DockingAction; import docking.action.MenuData; import docking.tool.ToolConstants; +import docking.widgets.table.actions.DeleteTableRowAction; import ghidra.app.CorePluginPackage; import ghidra.app.context.NavigatableActionContext; import ghidra.app.context.NavigatableContextAction; @@ -37,7 +38,6 @@ import ghidra.framework.plugintool.PluginTool; import ghidra.framework.plugintool.util.PluginStatus; import ghidra.program.model.listing.Program; import ghidra.util.HelpLocation; -import ghidra.util.table.actions.DeleteTableRowAction; import ghidra.util.task.SwingUpdateManager; /** diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/scalartable/ScalarSearchProvider.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/scalartable/ScalarSearchProvider.java index 320ac43882..5dfbf74666 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/scalartable/ScalarSearchProvider.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/scalartable/ScalarSearchProvider.java @@ -26,6 +26,7 @@ import docking.*; import docking.widgets.label.GLabel; import docking.widgets.table.GTableFilterPanel; import docking.widgets.table.TableFilter; +import docking.widgets.table.actions.DeleteTableRowAction; import generic.theme.GIcon; import ghidra.app.plugin.core.scalartable.RangeFilterTextField.FilterType; import ghidra.framework.plugintool.ComponentProviderAdapter; @@ -35,7 +36,6 @@ import ghidra.program.model.scalar.Scalar; import ghidra.program.util.ProgramSelection; import ghidra.util.HelpLocation; import ghidra.util.table.*; -import ghidra.util.table.actions.DeleteTableRowAction; import ghidra.util.table.actions.MakeProgramSelectionAction; import help.HelpService; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/table/TableComponentProvider.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/table/TableComponentProvider.java index 59be2c2edd..709b17f2a1 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/table/TableComponentProvider.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/table/TableComponentProvider.java @@ -31,6 +31,7 @@ import docking.action.DockingAction; import docking.action.MenuData; import docking.widgets.table.AbstractSortedTableModel; import docking.widgets.table.GTable; +import docking.widgets.table.actions.DeleteTableRowAction; import generic.theme.GIcon; import ghidra.app.nav.Navigatable; import ghidra.app.nav.NavigatableRemovalListener; @@ -44,7 +45,6 @@ import ghidra.program.model.listing.Program; import ghidra.program.util.*; import ghidra.util.HelpLocation; import ghidra.util.table.*; -import ghidra.util.table.actions.DeleteTableRowAction; import ghidra.util.table.actions.MakeProgramSelectionAction; import utility.function.Callback; import utility.function.Dummy; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/table/TableServicePlugin.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/table/TableServicePlugin.java index f58bceb15c..bdf64cfa60 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/table/TableServicePlugin.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/table/TableServicePlugin.java @@ -20,6 +20,7 @@ import java.util.*; import javax.swing.Icon; +import docking.widgets.table.actions.DeleteTableRowAction; import ghidra.app.CorePluginPackage; import ghidra.app.events.ProgramClosedPluginEvent; import ghidra.app.nav.Navigatable; @@ -38,7 +39,6 @@ import ghidra.framework.plugintool.util.PluginStatus; import ghidra.program.model.listing.Program; import ghidra.util.Swing; import ghidra.util.table.GhidraProgramTableModel; -import ghidra.util.table.actions.DeleteTableRowAction; import ghidra.util.task.SwingUpdateManager; //@formatter:off @@ -170,9 +170,7 @@ public class TableServicePlugin extends ProgramPlugin } void remove(TableComponentProvider provider) { - Iterator iter = programMap.keySet().iterator(); - while (iter.hasNext()) { - Program p = iter.next(); + for (Program p : programMap.keySet()) { List> list = programMap.get(p); if (list.remove(provider)) { if (list.size() == 0) { @@ -184,9 +182,7 @@ public class TableServicePlugin extends ProgramPlugin } void removeDialog(TableServiceTableChooserDialog dialog) { - Iterator iter = programToDialogMap.keySet().iterator(); - while (iter.hasNext()) { - Program p = iter.next(); + for (Program p : programToDialogMap.keySet()) { List list = programToDialogMap.get(p); if (list.remove(dialog)) { if (list.size() == 0) { @@ -213,9 +209,7 @@ public class TableServicePlugin extends ProgramPlugin private List> getProviders() { List> clist = new ArrayList<>(); - Iterator>> iter = programMap.values().iterator(); - while (iter.hasNext()) { - List> list = iter.next(); + for (List> list : programMap.values()) { clist.addAll(list); } return clist; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/services/DataTypeReference.java b/Ghidra/Features/Base/src/main/java/ghidra/app/services/DataTypeReference.java index 764e2ccd2b..96b8d980b1 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/services/DataTypeReference.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/services/DataTypeReference.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. @@ -15,7 +15,7 @@ */ package ghidra.app.services; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext; +import docking.widgets.search.SearchLocationContext; import ghidra.program.model.address.Address; import ghidra.program.model.data.DataType; import ghidra.program.model.listing.Function; @@ -31,10 +31,10 @@ public class DataTypeReference { private Address address; /** A preview of how the reference was used */ - private LocationReferenceContext context; + private SearchLocationContext context; public DataTypeReference(DataType dataType, String fieldName, Function function, - Address address, LocationReferenceContext context) { + Address address, SearchLocationContext context) { this.dataType = dataType; this.fieldName = fieldName; this.function = function; @@ -54,7 +54,7 @@ public class DataTypeReference { return address; } - public LocationReferenceContext getContext() { + public SearchLocationContext getContext() { return context; } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/features/base/memsearch/gui/MemorySearchProvider.java b/Ghidra/Features/Base/src/main/java/ghidra/features/base/memsearch/gui/MemorySearchProvider.java index a572739c9b..972e0d48de 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/features/base/memsearch/gui/MemorySearchProvider.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/features/base/memsearch/gui/MemorySearchProvider.java @@ -33,6 +33,7 @@ import docking.action.builder.ToggleActionBuilder; import docking.util.GGlassPaneMessage; import docking.widgets.OptionDialog; import docking.widgets.OptionDialogBuilder; +import docking.widgets.table.actions.DeleteTableRowAction; import generic.theme.GIcon; import ghidra.app.context.NavigatableActionContext; import ghidra.app.nav.Navigatable; @@ -57,7 +58,6 @@ import ghidra.util.Msg; import ghidra.util.layout.VerticalLayout; import ghidra.util.table.GhidraTable; import ghidra.util.table.SelectionNavigationAction; -import ghidra.util.table.actions.DeleteTableRowAction; import ghidra.util.table.actions.MakeProgramSelectionAction; import resources.Icons; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/features/base/quickfix/QuickFixTableProvider.java b/Ghidra/Features/Base/src/main/java/ghidra/features/base/quickfix/QuickFixTableProvider.java index 5f6c966b36..66d97cd89e 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/features/base/quickfix/QuickFixTableProvider.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/features/base/quickfix/QuickFixTableProvider.java @@ -29,6 +29,7 @@ import docking.action.ToggleDockingAction; import docking.action.builder.ActionBuilder; import docking.action.builder.ToggleActionBuilder; import docking.widgets.table.GTable; +import docking.widgets.table.actions.DeleteTableRowAction; import docking.widgets.table.threaded.ThreadedTableModel; import generic.theme.GIcon; import ghidra.app.nav.Navigatable; @@ -38,7 +39,6 @@ import ghidra.program.model.listing.Program; import ghidra.program.util.ProgramTask; import ghidra.util.HelpLocation; import ghidra.util.table.*; -import ghidra.util.table.actions.DeleteTableRowAction; import ghidra.util.table.actions.MakeProgramSelectionAction; import ghidra.util.task.TaskLauncher; import ghidra.util.task.TaskMonitor; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/util/table/GhidraTable.java b/Ghidra/Features/Base/src/main/java/ghidra/util/table/GhidraTable.java index 22059abe04..c86e8ccebd 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/util/table/GhidraTable.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/util/table/GhidraTable.java @@ -281,6 +281,8 @@ public class GhidraTable extends GTable { *

* This method differs from {@link #navigate(int, int)} in that this method will not navigate if * {@link #navigateOnSelection} is false. + * @param row the row + * @param column the column */ protected void navigateOnCurrentSelection(int row, int column) { if (!navigateOnSelection) { diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/app/plugin/core/console/ConsolePluginTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/app/plugin/core/console/ConsolePluginTest.java index e7a48de523..04dbf57fc5 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/app/plugin/core/console/ConsolePluginTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/app/plugin/core/console/ConsolePluginTest.java @@ -31,8 +31,9 @@ import org.junit.*; import docking.action.DockingActionIf; import docking.util.AnimationUtils; -import docking.widgets.FindDialog; -import docking.widgets.TextComponentSearcher; +import docking.widgets.*; +import docking.widgets.search.TextComponentSearcher; +import docking.widgets.table.GTable; import generic.jar.ResourceFile; import generic.theme.GColor; import ghidra.app.script.GhidraScript; @@ -78,8 +79,6 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { placeCursorAtBeginning(); findDialog = showFindDialog(); - String searchText = "Hello"; - find(searchText); } @After @@ -88,12 +87,47 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { env.dispose(); } + @Test + public void testFindAll() throws Exception { + + String searchText = "Hello"; + findAll(searchText); + assertFalse(findDialog.isShowing()); + List matches = getExpectedMatches(searchText); + assertEquals(3, matches.size()); + + verfiyHighlightColor(matches); + + FindDialogResultsProvider resultsProvider = + waitForComponentProvider(FindDialogResultsProvider.class); + List results = resultsProvider.getResults(); + assertEquals(matches.size(), results.size()); + + int row = 0; + selectRow(resultsProvider, row); + assertActiveHighlight(matches.get(row)); + assertCursorInMatch(matches.get(row)); + + row = 1; + selectRow(resultsProvider, row); + assertActiveHighlight(matches.get(row)); + assertCursorInMatch(matches.get(row)); + + row = 2; + selectRow(resultsProvider, row); + assertActiveHighlight(matches.get(row)); + assertCursorInMatch(matches.get(row)); + } + @Test public void testFindHighlights() throws Exception { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); - verfyHighlightColor(matches); + verfiyHighlightColor(matches); close(findDialog); verifyDefaultBackgroundColorForAllText(); @@ -102,18 +136,21 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { @Test public void testFindHighlights_ChangeSearchText() throws Exception { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); - verfyHighlightColor(matches); + verfiyHighlightColor(matches); // Change the search text after the first search and make sure the new text is found and // highlighted correctly. String newSearchText = "java"; runSwing(() -> findDialog.setSearchText(newSearchText)); pressButtonByText(findDialog, "Next"); - matches = getMatches(); + matches = getExpectedMatches(); assertEquals(2, matches.size()); - verfyHighlightColor(matches); + verfiyHighlightColor(matches); close(findDialog); verifyDefaultBackgroundColorForAllText(); @@ -122,9 +159,12 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { @Test public void testFindHighlights_ChangeDocumentText() throws Exception { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); - verfyHighlightColor(matches); + verfiyHighlightColor(matches); runSwing(() -> textPane.setText("This is some\nnew text.")); @@ -135,7 +175,10 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { @Test public void testMovingCursorUpdatesActiveHighlight() { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); TestTextMatch first = matches.get(0); TestTextMatch second = matches.get(1); @@ -154,7 +197,10 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { @Test public void testFindNext_ChangeDocumentText() throws Exception { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); TestTextMatch first = matches.get(0); TestTextMatch second = matches.get(1); @@ -177,11 +223,11 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { // worth worrying about.) next(); - matches = getMatches(); + matches = getExpectedMatches(); assertEquals(5, matches.size()); // 3 old matches plus 2 new matches - second = matches.get(1); - assertCursorInMatch(second); - assertActiveHighlight(second); + TestTextMatch third = matches.get(1); + assertCursorInMatch(third); + assertActiveHighlight(third); next(); // third next(); // fourth @@ -193,10 +239,28 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { close(findDialog); } + @Test + public void testFindNext_NoMatches() throws Exception { + + String searchText = "Goodbye"; + find(searchText); + + List matches = getExpectedMatches(); + assertEquals(0, matches.size()); + + String status = runSwing(() -> findDialog.getStatusText()); + assertEquals("Not found", status); + + runSwing(() -> findDialog.dispose()); + } + @Test public void testFindNext() throws Exception { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); TestTextMatch first = matches.get(0); TestTextMatch second = matches.get(1); @@ -224,7 +288,10 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { @Test public void testFindNext_MoveCaret() throws Exception { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); TestTextMatch first = matches.get(0); TestTextMatch second = matches.get(1); @@ -247,7 +314,10 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { @Test public void testFindPrevious() throws Exception { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); TestTextMatch first = matches.get(0); TestTextMatch second = matches.get(1); @@ -277,7 +347,10 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { @Test public void testFindPrevious_MoveCaret() throws Exception { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); TestTextMatch first = matches.get(0); TestTextMatch second = matches.get(1); @@ -300,9 +373,12 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { @Test public void testClear() throws Exception { - List matches = getMatches(); + String searchText = "Hello"; + find(searchText); + + List matches = getExpectedMatches(); assertEquals(3, matches.size()); - verfyHighlightColor(matches); + verfiyHighlightColor(matches); clear(); @@ -331,17 +407,26 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { private void next() { pressButtonByText(findDialog, "Next"); - waitForSwing(); + waitForTasks(); } private void previous() { pressButtonByText(findDialog, "Previous"); - waitForSwing(); + waitForTasks(); + } + + private void findAll(String text) { + runSwing(() -> findDialog.setSearchText(text)); + pressButtonByText(findDialog, "Find All"); + waitForTasks(); } private void assertSearchModelHasNoSearchResults() { TextComponentSearcher searcher = (TextComponentSearcher) findDialog.getSearcher(); + if (searcher == null) { + return; // assume the searcher was disposed + } assertFalse(searcher.hasSearchResults()); } @@ -361,10 +446,11 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { private void assertActiveHighlight(TestTextMatch match) { GColor expectedHlColor = new GColor("color.bg.find.highlight.active"); - assertActiveHighlight(match, expectedHlColor); + assertActiveHighlight(match, expectedHlColor, true); } - private void assertActiveHighlight(TestTextMatch match, Color expectedHlColor) { + private void assertActiveHighlight(TestTextMatch match, Color expectedHlColor, + boolean isActive) { Highlight matchHighlight = runSwing(() -> { Highlighter highlighter = textPane.getHighlighter(); @@ -379,10 +465,14 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { return null; }); - assertNotNull(matchHighlight); + assertNotNull("No highlight found for " + match, matchHighlight); DefaultHighlightPainter painter = (DefaultHighlightPainter) matchHighlight.getPainter(); Color actualHlColor = painter.getColor(); - assertEquals(expectedHlColor, actualHlColor); + + String msg = "Expected %s highlight color for match %s".formatted( + isActive ? "active" : "inactive", match); + + assertEquals(msg, expectedHlColor, actualHlColor); } private void placeCursorAtBeginning() { @@ -396,7 +486,12 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { waitForSwing(); } - private void verfyHighlightColor(List matches) + private void selectRow(FindDialogResultsProvider resultsProvider, int row) { + GTable table = resultsProvider.getTable(); + runSwing(() -> table.selectRow(row)); + } + + private void verfiyHighlightColor(List matches) throws Exception { GColor nonActiveHlColor = new GColor("color.bg.find.highlight"); @@ -405,10 +500,11 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { int caret = textPane.getCaretPosition(); for (TestTextMatch match : matches) { Color expectedColor = nonActiveHlColor; - if (match.contains(caret)) { + boolean isActive = match.contains(caret); + if (isActive) { expectedColor = activeHlColor; } - assertActiveHighlight(match, expectedColor); + assertActiveHighlight(match, expectedColor, isActive); } } @@ -426,9 +522,14 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { } } - private List getMatches() { + private List getExpectedMatches() { String searchText = findDialog.getSearchText(); + assertFalse(searchText.isEmpty()); + return getExpectedMatches(searchText); + } + + private List getExpectedMatches(String searchText) { List results = new ArrayList<>(); String text = runSwing(() -> textPane.getText()); @@ -449,8 +550,7 @@ public class ConsolePluginTest extends AbstractGhidraHeadedIntegrationTest { private void find(String text) { runSwing(() -> findDialog.setSearchText(text)); - pressButtonByText(findDialog, "Next"); - waitForTasks(); + next(); } private FindDialog showFindDialog() { diff --git a/Ghidra/Features/CodeCompare/src/main/java/ghidra/features/codecompare/decompile/DecompilerDiffViewFindAction.java b/Ghidra/Features/CodeCompare/src/main/java/ghidra/features/codecompare/decompile/DecompilerDiffViewFindAction.java index b721cdb47d..a37d53c14a 100644 --- a/Ghidra/Features/CodeCompare/src/main/java/ghidra/features/codecompare/decompile/DecompilerDiffViewFindAction.java +++ b/Ghidra/Features/CodeCompare/src/main/java/ghidra/features/codecompare/decompile/DecompilerDiffViewFindAction.java @@ -87,7 +87,7 @@ public class DecompilerDiffViewFindAction extends DockingAction { private FindDialog createFindDialog(DecompilerPanel decompilerPanel, Side side) { String title = (side == LEFT ? "Left" : "Right"); - title += " Decompiler Find Text"; + title += " Decompiler Find"; FindDialog dialog = new FindDialog(title, new DecompilerSearcher(decompilerPanel)) { @Override @@ -98,6 +98,15 @@ public class DecompilerDiffViewFindAction extends DockingAction { }; dialog.setHelpLocation(new HelpLocation(HelpTopics.DECOMPILER, "ActionFind")); + /* + Find All will keep the results around in a separate window. When those results are + clicked, the Decompiler will update the current function. This can cause the function + comparison window to become out of sync with the available functions being compared. We + could update the function comparison to handle this case, but that doesn't seem worth it + at this time. For now, just disable the Find All. + */ + dialog.setFindAllEnabled(false); + return dialog; } diff --git a/Ghidra/Features/Decompiler/data/decompiler.theme.properties b/Ghidra/Features/Decompiler/data/decompiler.theme.properties index 816d20be6f..62635b64fd 100644 --- a/Ghidra/Features/Decompiler/data/decompiler.theme.properties +++ b/Ghidra/Features/Decompiler/data/decompiler.theme.properties @@ -19,7 +19,8 @@ color.bg.decompiler.current.variable = color.palette.highlight.transparent.yello color.bg.decompiler.highlights.middle.mouse = color.bg.highlight color.bg.decompiler.highlights.default = color.palette.highlight.transparent.yellow color.bg.decompiler.highlights.special = color.palette.crimson -color.bg.decompiler.highlights.find = color.palette.slateblue +color.bg.decompiler.highlights.find = color.palette.cornflowerblue +color.bg.decompiler.highlights.find.active = color.palette.lightskyblue color.bg.decompiler.pcode.dfg.vertex.default = color.palette.red color.bg.decompiler.pcode.dfg.vertex.selected = color.palette.lightcoral diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/DecompileOptions.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/DecompileOptions.java index 55453956c7..53abe316d3 100644 --- a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/DecompileOptions.java +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/DecompileOptions.java @@ -443,6 +443,7 @@ public class DecompileOptions { private static final String SEARCH_HIGHLIGHT_MSG = "Display.Color for Highlighting Find Matches"; private static final GColor SEARCH_HIGHLIGHT_COLOR = new GColor("color.bg.decompiler.highlights.find"); + private static final GColor SEARCH_HIGHLIGHT_ACTIVE_COLOR = new GColor("color.bg.decompiler.highlights.find.active"); private static final String HIGHLIGHT_MIDDLE_MOUSE_MSG = "Display.Color for Middle Mouse"; private static final GColor HIGHLIGHT_MIDDLE_MOUSE_COLOR = new GColor("color.bg.decompiler.highlights.middle.mouse"); @@ -1134,6 +1135,13 @@ public class DecompileOptions { return HIGHLIGHT_MIDDLE_MOUSE_COLOR; } + /** + * @return color used to highlight the active search result + */ + public Color getActiveSearchHighlightColor() { + return SEARCH_HIGHLIGHT_ACTIVE_COLOR; + } + /** * @return color used to highlight search results */ diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/component/DecompilerFindDialog.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/component/DecompilerFindDialog.java index 52497d160e..0643d74f30 100644 --- a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/component/DecompilerFindDialog.java +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/component/DecompilerFindDialog.java @@ -15,251 +15,16 @@ */ package ghidra.app.decompiler.component; -import java.awt.Component; -import java.util.List; -import java.util.stream.Collectors; - -import javax.swing.ListSelectionModel; - -import docking.DockingWindowManager; -import docking.Tool; import docking.widgets.FindDialog; -import docking.widgets.SearchLocation; -import docking.widgets.button.GButton; -import docking.widgets.fieldpanel.support.FieldLocation; -import docking.widgets.table.*; -import ghidra.app.plugin.core.decompile.actions.DecompilerSearchLocation; import ghidra.app.plugin.core.decompile.actions.DecompilerSearcher; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext; -import ghidra.app.plugin.core.table.TableComponentProvider; import ghidra.app.util.HelpTopics; -import ghidra.app.util.query.TableService; -import ghidra.docking.settings.Settings; -import ghidra.framework.plugintool.ServiceProvider; -import ghidra.program.model.listing.Program; -import ghidra.program.util.ProgramLocation; -import ghidra.program.util.ProgramSelection; import ghidra.util.HelpLocation; -import ghidra.util.Msg; -import ghidra.util.datastruct.Accumulator; -import ghidra.util.exception.CancelledException; -import ghidra.util.table.*; -import ghidra.util.table.column.AbstractGhidraColumnRenderer; -import ghidra.util.table.column.GColumnRenderer; -import ghidra.util.task.TaskMonitor; public class DecompilerFindDialog extends FindDialog { - private DecompilerPanel decompilerPanel; - private GButton showAllButton; - public DecompilerFindDialog(DecompilerPanel decompilerPanel) { - super("Decompiler Find Text", new DecompilerSearcher(decompilerPanel)); - this.decompilerPanel = decompilerPanel; + super("Decompiler Find", new DecompilerSearcher(decompilerPanel)); setHelpLocation(new HelpLocation(HelpTopics.DECOMPILER, "ActionFind")); - - showAllButton = new GButton("Search All"); - showAllButton.addActionListener(e -> showAll()); - - // move this button to the end - removeButton(dismissButton); - - addButton(showAllButton); - addButton(dismissButton); - } - - @Override - protected void enableButtons(boolean b) { - super.enableButtons(b); - showAllButton.setEnabled(b); - } - - private void showAll() { - - String searchText = getSearchText(); - - close(); - - DockingWindowManager dwm = DockingWindowManager.getActiveInstance(); - Tool tool = dwm.getTool(); - TableService tableService = tool.getService(TableService.class); - if (tableService == null) { - Msg.error(this, - "Cannot use the Decompiler Search All action without having a TableService " + - "installed"); - return; - } - - List results = searcher.searchAll(searchText, useRegex()); - if (!results.isEmpty()) { - // save off searches that find results so users can reuse them later - storeSearchText(getSearchText()); - } - - Program program = decompilerPanel.getProgram(); - DecompilerFindResultsModel model = new DecompilerFindResultsModel(tool, program, results); - - String title = "Decompiler Search '%s'".formatted(getSearchText()); - String type = "Decompiler Search Results"; - String subMenuName = "Search"; - TableComponentProvider provider = - tableService.showTable(title, type, model, subMenuName, null); - - // The Decompiler does not support some of the table's basic actions, such as making - // selections for a given row, so remove them. - provider.removeAllActions(); - provider.installRemoveItemsAction(); - - GhidraThreadedTablePanel panel = provider.getThreadedTablePanel(); - GhidraTable table = panel.getTable(); - - // add row listener to go to the field for that row - ListSelectionModel selectionModel = table.getSelectionModel(); - selectionModel.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - selectionModel.addListSelectionListener(lse -> { - if (lse.getValueIsAdjusting()) { - return; - } - - int row = table.getSelectedRow(); - if (row == -1) { - searcher.highlightSearchResults(null); - return; - } - - DecompilerSearchLocation location = model.getRowObject(row); - - notifySearchHit(location); - }); - - // add listener to table closed to clear highlights - provider.setClosedCallback(() -> decompilerPanel.setSearchResults(null)); - - // set the tab text to the short and descriptive search term - provider.setTabText("'%s'".formatted(getSearchText())); - } - -//================================================================================================= -// Inner Classes -//================================================================================================= - - private class DecompilerFindResultsModel - extends GhidraProgramTableModel { - - private List searchLocations; - - DecompilerFindResultsModel(ServiceProvider sp, Program program, - List searchLocations) { - super("Decompiler Search All Results", sp, program, null); - this.searchLocations = searchLocations.stream() - .map(l -> (DecompilerSearchLocation) l) - .collect(Collectors.toList()); - } - - @Override - protected TableColumnDescriptor createTableColumnDescriptor() { - - TableColumnDescriptor descriptor = - new TableColumnDescriptor<>(); - descriptor.addVisibleColumn(new LineNumberColumn(), 1, true); - descriptor.addVisibleColumn(new ContextColumn()); - return descriptor; - } - - @Override - protected void doLoad(Accumulator accumulator, - TaskMonitor monitor) - throws CancelledException { - - for (DecompilerSearchLocation location : searchLocations) { - accumulator.add(location); - } - } - - @Override - public ProgramLocation getProgramLocation(int modelRow, int modelColumn) { - return null; // This doesn't really make sense for this model - } - - @Override - public ProgramSelection getProgramSelection(int[] modelRows) { - return new ProgramSelection(); // This doesn't really make sense for this model - } - - private class LineNumberColumn - extends AbstractDynamicTableColumnStub { - - @Override - public Integer getValue(DecompilerSearchLocation rowObject, Settings settings, - ServiceProvider sp) throws IllegalArgumentException { - FieldLocation fieldLocation = rowObject.getFieldLocation(); - return fieldLocation.getIndex().intValue() + 1; // +1 for 1-based lines - } - - @Override - public String getColumnName() { - return "Line"; - } - - @Override - public int getColumnPreferredWidth() { - return 75; - } - } - - private class ContextColumn - extends - AbstractDynamicTableColumnStub { - - private GColumnRenderer renderer = new ContextCellRenderer(); - - @Override - public LocationReferenceContext getValue(DecompilerSearchLocation rowObject, - Settings settings, - ServiceProvider sp) throws IllegalArgumentException { - - LocationReferenceContext context = rowObject.getContext(); - return context; - // return rowObject.getTextLine(); - } - - @Override - public String getColumnName() { - return "Context"; - } - - @Override - public GColumnRenderer getColumnRenderer() { - return renderer; - } - - private class ContextCellRenderer - extends AbstractGhidraColumnRenderer { - - { - // the context uses html - setHTMLRenderingEnabled(true); - } - - @Override - public Component getTableCellRendererComponent(GTableCellRenderingData data) { - - // initialize - super.getTableCellRendererComponent(data); - - DecompilerSearchLocation match = (DecompilerSearchLocation) data.getRowObject(); - LocationReferenceContext context = match.getContext(); - String text = context.getBoldMatchingText(); - setText(text); - return this; - } - - @Override - public String getFilterString(LocationReferenceContext context, Settings settings) { - return context.getPlainText(); - } - } - } } } diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/component/DecompilerPanel.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/component/DecompilerPanel.java index 18ba63f001..0f5011f2dc 100644 --- a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/component/DecompilerPanel.java +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/decompiler/component/DecompilerPanel.java @@ -29,7 +29,6 @@ import docking.DockingUtils; import docking.util.AnimationUtils; import docking.util.SwingAnimationCallback; import docking.widgets.EventTrigger; -import docking.widgets.SearchLocation; import docking.widgets.fieldpanel.FieldPanel; import docking.widgets.fieldpanel.LayoutModel; import docking.widgets.fieldpanel.field.Field; @@ -44,6 +43,7 @@ import ghidra.app.decompiler.component.margin.*; import ghidra.app.decompiler.location.*; import ghidra.app.plugin.core.decompile.DecompilerClipboardProvider; import ghidra.app.plugin.core.decompile.actions.DecompilerSearchLocation; +import ghidra.app.plugin.core.decompile.actions.DecompilerSearchResults; import ghidra.app.util.viewer.util.ScrollpaneAlignedHorizontalLayout; import ghidra.program.model.address.*; import ghidra.program.model.listing.*; @@ -95,8 +95,11 @@ public class DecompilerPanel extends JPanel implements FieldMouseListener, Field private int middleMouseHighlightButton; private Color middleMouseHighlightColor; private Color currentVariableHighlightColor; + + private Color activeSearchHighlightColor; private Color searchHighlightColor; - private SearchLocation currentSearchLocation; + + private DecompilerSearchResults currentSearchResults; private DecompileData decompileData = new EmptyDecompileData("No Function"); private final DecompilerClipboardProvider clipboard; @@ -145,6 +148,7 @@ public class DecompilerPanel extends JPanel implements FieldMouseListener, Field decompilerHoverProvider = new DecompilerHoverProvider(); + activeSearchHighlightColor = options.getActiveSearchHighlightColor(); searchHighlightColor = options.getSearchHighlightColor(); currentVariableHighlightColor = options.getCurrentVariableHighlightColor(); middleMouseHighlightColor = options.getMiddleMouseHighlightColor(); @@ -535,7 +539,10 @@ public class DecompilerPanel extends JPanel implements FieldMouseListener, Field } // don't highlight search results across functions - currentSearchLocation = null; + if (currentSearchResults != null) { + currentSearchResults.decompilerUpdated(); + currentSearchResults = null; + } if (function != null) { highlightController.reapplyAllHighlights(function); @@ -1105,13 +1112,32 @@ public class DecompilerPanel extends JPanel implements FieldMouseListener, Field return null; } - public void setSearchResults(SearchLocation searchLocation) { - currentSearchLocation = searchLocation; + public void clearSearchResults(DecompilerSearchResults searchResults) { + if (currentSearchResults == searchResults) { + currentSearchResults = null; + repaint(); + } + } + + public void setSearchResults(DecompilerSearchResults searchResults) { + currentSearchResults = searchResults; + + if (currentSearchResults != null) { + DecompilerSearchLocation location = currentSearchResults.getActiveLocation(); + if (location != null) { + setCursorPosition(location.getFieldLocation()); + } + } + repaint(); } - public DecompilerSearchLocation getSearchResults() { - return (DecompilerSearchLocation) currentSearchLocation; + public DecompilerSearchLocation getActiveSearchLocation() { + if (currentSearchResults == null) { + return null; + } + DecompilerSearchLocation location = currentSearchResults.getActiveLocation(); + return location; } public Color getCurrentVariableHighlightColor() { @@ -1370,23 +1396,30 @@ public class DecompilerPanel extends JPanel implements FieldMouseListener, Field @Override public Highlight[] createHighlights(Field field, String text, int cursorTextOffset) { - if (currentSearchLocation == null) { + if (currentSearchResults == null) { return new Highlight[0]; } ClangTextField cField = (ClangTextField) field; - int highlightLine = cField.getLineNumber(); - - FieldLocation searchCursorLocation = - ((DecompilerSearchLocation) currentSearchLocation).getFieldLocation(); - int searchLineNumber = searchCursorLocation.getIndex().intValue() + 1; - if (highlightLine != searchLineNumber) { - // only highlight the match on the actual line + int lineNumber = cField.getLineNumber(); + Map> locationsByLine = + currentSearchResults.getLocationsByLine(); + List locationsOnLine = locationsByLine.get(lineNumber); + if (locationsOnLine.isEmpty()) { return new Highlight[0]; } - return new Highlight[] { new Highlight(currentSearchLocation.getStartIndexInclusive(), - currentSearchLocation.getEndIndexInclusive(), searchHighlightColor) }; + DecompilerSearchLocation activeLocation = currentSearchResults.getActiveLocation(); + List highlights = new ArrayList<>(); + for (DecompilerSearchLocation location : locationsOnLine) { + Color c = + location == activeLocation ? activeSearchHighlightColor : searchHighlightColor; + int start = location.getStartIndexInclusive(); + int end = location.getEndIndexInclusive(); + highlights.add(new Highlight(start, end, c)); + } + + return highlights.toArray(Highlight[]::new); } } diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerCursorPosition.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerCursorPosition.java index 98be2265b6..4cfaa021d7 100644 --- a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerCursorPosition.java +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerCursorPosition.java @@ -1,13 +1,12 @@ /* ### * IP: GHIDRA - * REVIEWED: YES * * 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. @@ -35,4 +34,9 @@ public class DecompilerCursorPosition extends CursorPosition { public void setOffset(int offset) { location.col += offset; } + + @Override + public String toString() { + return location.toString(); + } } diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearchLocation.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearchLocation.java index 600835d6c4..8ac6ad901a 100644 --- a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearchLocation.java +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearchLocation.java @@ -18,22 +18,20 @@ package ghidra.app.plugin.core.decompile.actions; import docking.widgets.CursorPosition; import docking.widgets.SearchLocation; import docking.widgets.fieldpanel.support.FieldLocation; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext; +import docking.widgets.search.SearchLocationContext; public class DecompilerSearchLocation extends SearchLocation { private final FieldLocation fieldLocation; private String textLine; - private LocationReferenceContext context; public DecompilerSearchLocation(FieldLocation fieldLocation, int startIndexInclusive, - int endIndexInclusive, String searchText, boolean forwardDirection, String textLine, - LocationReferenceContext context) { + int endIndexInclusive, String text, boolean forwardDirection, String textLine, + int lineNumber, SearchLocationContext context) { - super(startIndexInclusive, endIndexInclusive, searchText, forwardDirection); + super(startIndexInclusive, endIndexInclusive, text, lineNumber, context); this.fieldLocation = fieldLocation; this.textLine = textLine; - this.context = context; } public FieldLocation getFieldLocation() { @@ -44,10 +42,6 @@ public class DecompilerSearchLocation extends SearchLocation { return textLine; } - public LocationReferenceContext getContext() { - return context; - } - @Override public CursorPosition getCursorPosition() { return new DecompilerCursorPosition(fieldLocation); @@ -57,4 +51,15 @@ public class DecompilerSearchLocation extends SearchLocation { protected String fieldsToString() { return super.fieldsToString() + ", fieldLocation=" + fieldLocation; } + + public boolean contains(FieldLocation other) { + int line = getLineNumber(); + int otherLine = other.getIndex().intValue() + 1; // +1 for zero based + if (line != otherLine) { + return false; + } + + int col = other.getCol(); + return contains(col); + } } diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearchResults.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearchResults.java new file mode 100644 index 0000000000..812b0fcdfd --- /dev/null +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearchResults.java @@ -0,0 +1,245 @@ +/* ### + * 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.decompile.actions; + +import java.time.Duration; +import java.util.*; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import docking.widgets.SearchLocation; +import docking.widgets.fieldpanel.support.FieldLocation; +import docking.widgets.search.SearchResults; +import ghidra.app.decompiler.component.DecompilerController; +import ghidra.app.decompiler.component.DecompilerPanel; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.Program; +import ghidra.program.util.ProgramLocation; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.TaskMonitor; +import ghidra.util.worker.Worker; + +public class DecompilerSearchResults extends SearchResults { + + // the location when the search was performed; used to know when the function has changed + private ProgramLocation programLocation; + private DecompilerPanel decompilerPanel; + private String searchText; + private List searchLocations; + private Map> locationsByLine; + private TreeMap matchesByPosition = new TreeMap<>(); + + private DecompilerSearchLocation activeLocation; + + DecompilerSearchResults(Worker worker, DecompilerPanel decompilerPanel, String searchText, + List searchLocations) { + super(worker); + this.decompilerPanel = decompilerPanel; + this.searchText = searchText; + + this.searchLocations = searchLocations; + this.programLocation = decompilerPanel.getCurrentLocation(); + + for (SearchLocation location : searchLocations) { + int line = location.getLineNumber(); + int col = location.getStartIndexInclusive(); + LinePosition lp = new LinePosition(line, col); + DecompilerSearchLocation dsl = (DecompilerSearchLocation) location; + matchesByPosition.put(lp, dsl); + } + } + + @Override + public String getName() { + DecompilerController controller = decompilerPanel.getController(); + Function function = controller.getFunction(); + return function.getName() + "()"; + } + + ProgramLocation getDecompileLocation() { + return programLocation; + } + + boolean isInvalid(String otherSearchText) { + if (isDifferentFunction()) { + return true; + } + return !searchText.equals(otherSearchText); + } + + @Override + public boolean isEmpty() { + return searchLocations.isEmpty(); + } + + @Override + public List getLocations() { + return searchLocations; + } + + public Map> getLocationsByLine() { + if (locationsByLine == null) { + locationsByLine = searchLocations.stream() + .map(l -> (DecompilerSearchLocation) l) + .collect(Collectors.groupingBy(l -> l.getLineNumber())); + } + return locationsByLine; + } + + private boolean isDifferentFunction() { + return !decompilerPanel.containsLocation(programLocation); + } + + private boolean isMyFunction() { + return decompilerPanel.containsLocation(programLocation); + } + + public DecompilerSearchLocation getContainingLocation(FieldLocation fieldLocation, + boolean searchForward) { + + // getNextLocation() will find the next matching location, starting at the given field + // location. The next location may or may not actually contain the given field location. + DecompilerSearchLocation nextLocation = getNextLocation(fieldLocation, searchForward); + if (nextLocation.contains(fieldLocation)) { + return nextLocation; + } + return null; + } + + @Override + public DecompilerSearchLocation getActiveLocation() { + return activeLocation; + } + + private void installSearchResults() { + if (isDifferentFunction()) { + return; // a different function was decompiled while we were running + } + decompilerPanel.setSearchResults(this); + } + + private void clearSearchResults() { + decompilerPanel.clearSearchResults(this); + } + + public void decompilerUpdated() { + // The decompiler has updated. It may have been upon our request. If not, deactivate. + if (isDifferentFunction()) { + deactivate(); + } + } + + @Override + public void deactivate() { + FindJob job = new SwingJob(this::clearSearchResults); + runJob(job); + } + + @Override + public void activate() { + FindJob job = createActivationJob().thenRunSwing(this::installSearchResults); + runJob(job); + } + + @Override + public void setActiveLocation(SearchLocation location) { + + if (activeLocation == location) { + return; + } + + activeLocation = (DecompilerSearchLocation) location; + if (location == null) { + return; + } + + // activate() will set the active search location + activate(); + } + + private ActivationJob createActivationJob() { + if (isMyFunction()) { + return createFinishedActivationJob(); // nothing to do + } + + return (ActivationJob) new ActivateFunctionJob() + .thenWait(this::isMyFunction, Duration.ofSeconds(5)); + } + + protected ActivationJob createFinishedActivationJob() { + return new ActivationJob(); + } + + @Override + public void dispose() { + setActiveLocation(null); + decompilerPanel.clearSearchResults(this); + searchLocations.clear(); + } + + DecompilerSearchLocation getNextLocation(FieldLocation startLocation, + boolean searchForward) { + + Entry entry; + int line = startLocation.getIndex().intValue() + 1; // +1 for zero based + int col = startLocation.getCol(); + LinePosition lp = new LinePosition(line, col); + if (searchForward) { + entry = matchesByPosition.ceilingEntry(lp); + } + else { + entry = matchesByPosition.floorEntry(lp); + } + + if (entry == null) { + return null; // no more matches in the current direction + } + + return entry.getValue(); + } + +//================================================================================================= +// Inner Classes +//================================================================================================= + + private class ActivateFunctionJob extends ActivationJob { + @Override + protected void doRun(TaskMonitor monitor) throws CancelledException { + if (isMyFunction()) { + return; // nothing to do + } + + DecompilerController controller = decompilerPanel.getController(); + Program program = programLocation.getProgram(); + controller.refreshDisplay(program, programLocation, null); + } + } + + private record LinePosition(int line, int col) implements Comparable { + + @Override + public int compareTo(LinePosition other) { + + int result = line - other.line; + if (result != 0) { + return result; + } + + return col - other.col; + } + } + +} diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearcher.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearcher.java index ba6dcbdcb1..6850f65f36 100644 --- a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearcher.java +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/DecompilerSearcher.java @@ -15,27 +15,31 @@ */ package ghidra.app.plugin.core.decompile.actions; -import java.util.*; +import java.util.ArrayList; +import java.util.List; import java.util.function.Function; import java.util.regex.*; -import docking.widgets.*; +import docking.widgets.CursorPosition; +import docking.widgets.SearchLocation; import docking.widgets.fieldpanel.field.Field; import docking.widgets.fieldpanel.support.FieldLocation; import docking.widgets.fieldpanel.support.RowColLocation; +import docking.widgets.search.*; import ghidra.app.decompiler.component.ClangTextField; import ghidra.app.decompiler.component.DecompilerPanel; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContextBuilder; import ghidra.util.Msg; import ghidra.util.UserSearchUtils; +import ghidra.util.worker.Worker; /** * A {@link FindDialogSearcher} for searching the text of the decompiler window. */ public class DecompilerSearcher implements FindDialogSearcher { + private Worker worker = Worker.createGuiWorker(); private DecompilerPanel decompilerPanel; + private DecompilerSearchResults searchResults; /** * Constructor @@ -76,98 +80,39 @@ public class DecompilerSearcher implements FindDialogSearcher { return new DecompilerCursorPosition(fieldLocation); } - @Override - public void setCursorPosition(CursorPosition position) { - decompilerPanel.setCursorPosition(((DecompilerCursorPosition) position).getFieldLocation()); - } - - @Override - public void highlightSearchResults(SearchLocation location) { - decompilerPanel.setSearchResults(location); - } - - @Override - public void clearHighlights() { - decompilerPanel.setSearchResults(null); - } - @Override public void dispose() { - clearHighlights(); + decompilerPanel.setSearchResults(null); + + if (searchResults != null) { + searchResults.dispose(); + } } - @Override - public SearchLocation search(String text, CursorPosition position, boolean searchForward, - boolean useRegex) { - DecompilerCursorPosition decompilerCursorPosition = (DecompilerCursorPosition) position; - FieldLocation startLocation = - getNextSearchStartLocation(decompilerCursorPosition, searchForward); - return doFind(text, startLocation, searchForward, useRegex); - } + private void updateSearchResults(String text, boolean useRegex) { + if (searchResults != null) { + if (!searchResults.isInvalid(text)) { - private FieldLocation getNextSearchStartLocation( - DecompilerCursorPosition decompilerCursorPosition, boolean searchForward) { + // the current results are still valid; ensure the highlights are still active + searchResults.activate(); + return; + } - FieldLocation startLocation = decompilerCursorPosition.getFieldLocation(); - DecompilerSearchLocation currentSearchLocation = decompilerPanel.getSearchResults(); - if (currentSearchLocation == null) { - return startLocation; // nothing to do; no prior search hit + searchResults.dispose(); + searchResults = null; } - // - // Special Case Handling: Start the search at the cursor location by default. - // However, if the cursor location is at the beginning of previous search hit, then - // move the cursor forward by one character to ensure the previous search hit is not - // found. - // - // Note: for a forward or backward search the cursor is placed at the beginning of the - // match. - // - if (Objects.equals(startLocation, currentSearchLocation.getFieldLocation())) { - - if (searchForward) { - // Given: - // -search text: 'fox' - // -search domain: 'What the |fox say' - // -a previous search hit just before 'fox' - // - // Move the cursor just past the 'f' so the next forward search will not - // find the current 'fox' hit. Thus the new search domain for this line - // will be: "ox say" - // - startLocation.col += 1; - } - else { - // Given: - // -search text: 'fox' - // -search domain: 'What the |fox say' - // -a previous search hit just before 'fox' - // - // Move the cursor just past the 'o' so the next backward search will not - // find the current 'fox' hit. Thus the new search domain for this line - // will be: "What the fo" - // - int length = currentSearchLocation.getMatchLength(); - startLocation.col += length - 1; - } - } - - return startLocation; + searchResults = doSearch(text, useRegex); } -//================================================================================================= -// Search Methods -//================================================================================================= + private DecompilerSearchResults doSearch(String searchText, boolean isRegex) { - @Override - public List searchAll(String searchString, boolean isRegex) { - - Pattern pattern = createPattern(searchString, isRegex); - Function function = createForwardMatchFunction(pattern); + Pattern pattern = createPattern(searchText, isRegex); + Function forwardMatcher = createForwardMatchFunction(pattern); FieldLocation start = new FieldLocation(); List results = new ArrayList<>(); - DecompilerSearchLocation searchLocation = findNext(function, searchString, start); + DecompilerSearchLocation searchLocation = findNext(forwardMatcher, searchText, start); while (searchLocation != null) { results.add(searchLocation); @@ -177,24 +122,62 @@ public class DecompilerSearcher implements FindDialogSearcher { int row = 0; // there is only 1 row int col = last.getCol() + 1; // move over one char to handle sub-matches start = new FieldLocation(line, field, row, col); - searchLocation = findNext(function, searchString, start); + searchLocation = findNext(forwardMatcher, searchText, start); } - return results; + DecompilerSearchResults newResults = + new DecompilerSearchResults(worker, decompilerPanel, searchText, results); + newResults.activate(); + return newResults; } - private DecompilerSearchLocation doFind(String searchString, FieldLocation currentLocation, - boolean forwardSearch, boolean isRegex) { +//================================================================================================= +// Search Methods +//================================================================================================= - Pattern pattern = createPattern(searchString, isRegex); + @Override + public SearchResults search(String text, CursorPosition position, boolean searchForward, + boolean useRegex) { - if (forwardSearch) { - Function function = createForwardMatchFunction(pattern); - return findNext(function, searchString, currentLocation); + updateSearchResults(text, useRegex); + + DecompilerCursorPosition cursorPosition = (DecompilerCursorPosition) position; + FieldLocation startLocation = getNextSearchStartLocation(cursorPosition, searchForward); + DecompilerSearchLocation location = + searchResults.getNextLocation(startLocation, searchForward); + if (location == null) { + return null; } - Function reverse = createReverseMatchFunction(pattern); - return findPrevious(reverse, searchString, currentLocation); + searchResults.setActiveLocation(location); + return searchResults; + } + + private FieldLocation getNextSearchStartLocation( + DecompilerCursorPosition decompilerCursorPosition, boolean searchForward) { + + FieldLocation cursor = decompilerCursorPosition.getFieldLocation(); + DecompilerSearchLocation containingLocation = + searchResults.getContainingLocation(cursor, searchForward); + + if (containingLocation == null) { + return cursor; // nothing to do; not on a search hit + } + + // the given cursor position is inside of an existing match + if (searchForward) { + cursor.col += 1; + } + else { + cursor.col = containingLocation.getStartIndexInclusive() - 1; + } + + return cursor; + } + + @Override + public SearchResults searchAll(String searchString, boolean isRegex) { + return doSearch(searchString, isRegex); } private Pattern createPattern(String searchString, boolean isRegex) { @@ -230,35 +213,6 @@ public class DecompilerSearcher implements FindDialogSearcher { } - private Function createReverseMatchFunction(Pattern pattern) { - - return textLine -> { - - Matcher matcher = pattern.matcher(textLine); - if (!matcher.find()) { - return SearchMatch.NO_MATCH; - } - - int start = matcher.start(); - int end = matcher.end(); - - // Since the matcher can only match from the start to end of line, we need to find all - // matches and then take the last match - - // Setting the region to one character past the previous match allows repeated matches - // within a match. The default behavior of the matcher is to start the match after - // the previous match found by find(). - matcher.region(start + 1, textLine.length()); - while (matcher.find()) { - start = matcher.start(); - end = matcher.end(); - matcher.region(start + 1, textLine.length()); - } - - return new SearchMatch(start, end, textLine); - }; - } - private DecompilerSearchLocation findNext(Function matcher, String searchString, FieldLocation currentLocation) { @@ -288,15 +242,16 @@ public class DecompilerSearcher implements FindDialogSearcher { FieldLineLocation lineInfo = getFieldIndexFromOffset(match.start, field); FieldLocation fieldLocation = new FieldLocation(i, lineInfo.fieldNumber(), 0, lineInfo.column()); - LocationReferenceContext context = createContext(fullLine, match); + int lineNumber = lineInfo.lineNumber(); + SearchLocationContext context = createContext(fullLine, match); return new DecompilerSearchLocation(fieldLocation, match.start, match.end - 1, - searchString, true, field.getText(), context); + searchString, true, field.getText(), lineNumber, context); } return null; } - private LocationReferenceContext createContext(String line, SearchMatch match) { - LocationReferenceContextBuilder builder = new LocationReferenceContextBuilder(); + private SearchLocationContext createContext(String line, SearchMatch match) { + SearchLocationContextBuilder builder = new SearchLocationContextBuilder(); int start = match.start; int end = match.end; builder.append(line.substring(0, start)); @@ -308,29 +263,6 @@ public class DecompilerSearcher implements FindDialogSearcher { return builder.build(); } - private DecompilerSearchLocation findPrevious(Function matcher, - String searchString, FieldLocation currentLocation) { - - List fields = decompilerPanel.getFields(); - int line = currentLocation.getIndex().intValue(); - for (int i = line; i >= 0; i--) { - ClangTextField field = (ClangTextField) fields.get(i); - String textLine = substring(field, (i == line) ? currentLocation : null, false); - SearchMatch match = matcher.apply(textLine); - if (match == SearchMatch.NO_MATCH) { - continue; - } - - FieldLineLocation lineInfo = getFieldIndexFromOffset(match.start, field); - FieldLocation fieldLocation = - new FieldLocation(i, lineInfo.fieldNumber(), 0, lineInfo.column()); - LocationReferenceContext context = createContext(field.getText(), match); - return new DecompilerSearchLocation(fieldLocation, match.start, match.end - 1, - searchString, false, field.getText(), context); - } - return null; - } - private String substring(ClangTextField textField, FieldLocation location, boolean forwardSearch) { @@ -364,9 +296,10 @@ public class DecompilerSearcher implements FindDialogSearcher { private FieldLineLocation getFieldIndexFromOffset(int screenOffset, ClangTextField textField) { RowColLocation rowColLocation = textField.textOffsetToScreenLocation(screenOffset); + int lineNumber = textField.getLineNumber(); // we use 0 here because currently there is only one field, which is the entire line - return new FieldLineLocation(0, rowColLocation.col()); + return new FieldLineLocation(0, lineNumber, rowColLocation.col()); } private static class SearchMatch { @@ -390,5 +323,5 @@ public class DecompilerSearcher implements FindDialogSearcher { } } - private record FieldLineLocation(int fieldNumber, int column) {} + private record FieldLineLocation(int fieldNumber, int lineNumber, int column) {} } diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/FindAction.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/FindAction.java index 5f7e390a96..3e845c0b87 100644 --- a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/FindAction.java +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/actions/FindAction.java @@ -81,6 +81,11 @@ public class FindAction extends AbstractDecompilerAction { dialog.setSearchText(text); } + if (dialog.isShowing()) { + dialog.toFront(); + return; + } + // show over the root frame, so the user can still see the Decompiler window context.getTool().showDialog(dialog); } diff --git a/Ghidra/Features/Decompiler/src/test.slow/java/ghidra/app/plugin/core/decompile/DecompilerFindDialogTest.java b/Ghidra/Features/Decompiler/src/test.slow/java/ghidra/app/plugin/core/decompile/DecompilerFindDialogTest.java index 4735cd56ba..8ec79f64d4 100644 --- a/Ghidra/Features/Decompiler/src/test.slow/java/ghidra/app/plugin/core/decompile/DecompilerFindDialogTest.java +++ b/Ghidra/Features/Decompiler/src/test.slow/java/ghidra/app/plugin/core/decompile/DecompilerFindDialogTest.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. @@ -340,7 +340,7 @@ public class DecompilerFindDialogTest extends AbstractDecompilerTest { assertCurrentLocation(line, column); DecompilerPanel panel = getDecompilerPanel(); - DecompilerSearchLocation searchResults = panel.getSearchResults(); + DecompilerSearchLocation searchResults = panel.getActiveSearchLocation(); FieldLocation searchCursorLocation = searchResults.getFieldLocation(); int searchLineNumber = searchCursorLocation.getIndex().intValue() + 1; assertEquals("Search result is on the wrong line", line, searchLineNumber); diff --git a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/DecompilerReference.java b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/DecompilerReference.java index fc383c6f8f..5f1a3529b9 100644 --- a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/DecompilerReference.java +++ b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/DecompilerReference.java @@ -17,9 +17,9 @@ package ghidra.app.extension.datatype.finder; import java.util.List; +import docking.widgets.search.SearchLocationContext; +import docking.widgets.search.SearchLocationContextBuilder; import ghidra.app.decompiler.*; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContextBuilder; import ghidra.app.services.DataTypeReference; import ghidra.app.services.FieldMatcher; import ghidra.program.model.address.Address; @@ -87,14 +87,14 @@ public abstract class DecompilerReference { return line; } - protected LocationReferenceContext getContext() { - LocationReferenceContext context = getContext(variable); + protected SearchLocationContext getContext() { + SearchLocationContext context = getContext(variable); return context; } - protected LocationReferenceContext getContext(DecompilerVariable var) { + protected SearchLocationContext getContext(DecompilerVariable var) { - LocationReferenceContextBuilder builder = new LocationReferenceContextBuilder(); + SearchLocationContextBuilder builder = new SearchLocationContextBuilder(); builder.append(line.getLineNumber() + ": "); List tokens = line.getAllTokens(); for (ClangToken token : tokens) { diff --git a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/VariableAccessDR.java b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/VariableAccessDR.java index a2ba9307a9..3982b995da 100644 --- a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/VariableAccessDR.java +++ b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/VariableAccessDR.java @@ -18,8 +18,8 @@ package ghidra.app.extension.datatype.finder; import java.util.ArrayList; import java.util.List; +import docking.widgets.search.SearchLocationContext; import ghidra.app.decompiler.*; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext; import ghidra.app.services.DataTypeReference; import ghidra.app.services.FieldMatcher; import ghidra.program.model.address.Address; @@ -240,7 +240,7 @@ public class VariableAccessDR extends DecompilerReference { protected DataTypeReference createReference(DecompilerVariable var) { DataType dataType = var.getDataType(); - LocationReferenceContext context = getContext(var); + SearchLocationContext context = getContext(var); Function function = var.getFunction(); Address address = getAddress(var); return new DataTypeReference(dataType, null, function, address, context); @@ -248,16 +248,16 @@ public class VariableAccessDR extends DecompilerReference { private DataTypeReference createReference(DecompilerVariable var, DecompilerVariable field) { DataType dataType = var.getDataType(); - LocationReferenceContext context = getContext(var); + SearchLocationContext context = getContext(var); Function function = var.getFunction(); Address address = getAddress(var); return new DataTypeReference(dataType, field.getName(), function, address, context); } @Override - protected LocationReferenceContext getContext(DecompilerVariable var) { + protected SearchLocationContext getContext(DecompilerVariable var) { DecompilerVariable field = findFieldFor(var); - LocationReferenceContext context = super.getContext(field); + SearchLocationContext context = super.getContext(field); return context; } diff --git a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/VariableDR.java b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/VariableDR.java index ff6825dedd..6cffe1f212 100644 --- a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/VariableDR.java +++ b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/extension/datatype/finder/VariableDR.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. @@ -17,8 +17,8 @@ package ghidra.app.extension.datatype.finder; import java.util.List; +import docking.widgets.search.SearchLocationContext; import ghidra.app.decompiler.*; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext; import ghidra.app.services.DataTypeReference; import ghidra.app.services.FieldMatcher; import ghidra.program.model.address.Address; @@ -74,7 +74,7 @@ public abstract class VariableDR extends DecompilerReference { String fieldName = fieldMatcher.getFieldName(); Function function = getFunction(); Address address = getAddress(); - LocationReferenceContext context = getContext(); + SearchLocationContext context = getContext(); results.add(new DataTypeReference(dataType, fieldName, function, address, context)); } } diff --git a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/plugin/core/search/DecompilerTextFinder.java b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/plugin/core/search/DecompilerTextFinder.java index 38a0ade391..a212e056c2 100644 --- a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/plugin/core/search/DecompilerTextFinder.java +++ b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/plugin/core/search/DecompilerTextFinder.java @@ -20,12 +20,12 @@ import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; +import docking.widgets.search.SearchLocationContext; +import docking.widgets.search.SearchLocationContextBuilder; import generic.json.Json; import ghidra.app.decompiler.*; import ghidra.app.decompiler.component.DecompilerUtils; import ghidra.app.decompiler.parallel.*; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContextBuilder; import ghidra.program.model.address.AddressSet; import ghidra.program.model.address.AddressSpace; import ghidra.program.model.listing.*; @@ -246,15 +246,15 @@ public class DecompilerTextFinder { TextLine firstLine = lineMatches.get(0); int lineNumber = firstLine.getLineNumber(); AddressSet addresses = getAddresses(function, firstLine.getCLine()); - LocationReferenceContext context = createMatchContext(lineMatches); + SearchLocationContext context = createMatchContext(lineMatches); TextMatch match = new TextMatch(function, addresses, lineNumber, searchText, context, true); callback.accept(match); } - private LocationReferenceContext createMatchContext(List matches) { + private SearchLocationContext createMatchContext(List matches) { - LocationReferenceContextBuilder builder = new LocationReferenceContextBuilder(); + SearchLocationContextBuilder builder = new SearchLocationContextBuilder(); for (TextLine line : matches) { if (!builder.isEmpty()) { builder.newline(); @@ -279,7 +279,7 @@ public class DecompilerTextFinder { return; } - LocationReferenceContextBuilder builder = new LocationReferenceContextBuilder(); + SearchLocationContextBuilder builder = new SearchLocationContextBuilder(); int start = matcher.start(); int end = matcher.end(); @@ -291,7 +291,7 @@ public class DecompilerTextFinder { int lineNumber = line.getLineNumber(); AddressSet addresses = getAddresses(function, line); - LocationReferenceContext context = builder.build(); + SearchLocationContext context = builder.build(); TextMatch match = new TextMatch(function, addresses, lineNumber, searchText, context, false); callback.accept(match); diff --git a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/plugin/core/search/DecompilerTextFinderTableModel.java b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/plugin/core/search/DecompilerTextFinderTableModel.java index c8b068265d..1b9892fa2a 100644 --- a/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/plugin/core/search/DecompilerTextFinderTableModel.java +++ b/Ghidra/Features/DecompilerDependent/src/main/java/ghidra/app/plugin/core/search/DecompilerTextFinderTableModel.java @@ -20,8 +20,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.regex.Pattern; +import docking.widgets.search.SearchLocationContext; import docking.widgets.table.*; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext; import ghidra.docking.settings.Settings; import ghidra.framework.plugintool.ServiceProvider; import ghidra.program.model.address.Address; @@ -142,12 +142,12 @@ public class DecompilerTextFinderTableModel extends GhidraProgramTableModel { + extends AbstractProgramBasedDynamicTableColumn { private ContextCellRenderer renderer = new ContextCellRenderer(); @Override - public LocationReferenceContext getValue(TextMatch rowObject, Settings settings, Program p, + public SearchLocationContext getValue(TextMatch rowObject, Settings settings, Program p, ServiceProvider sp) throws IllegalArgumentException { return rowObject.getContext(); } @@ -158,13 +158,13 @@ public class DecompilerTextFinderTableModel extends GhidraProgramTableModel getColumnRenderer() { + public GColumnRenderer getColumnRenderer() { return renderer; } } private class ContextCellRenderer - extends AbstractGhidraColumnRenderer { + extends AbstractGhidraColumnRenderer { { // the context uses html @@ -178,7 +178,7 @@ public class DecompilerTextFinderTableModel extends GhidraProgramTableModel { - URL url = e.getURL(); - if (!isValidHelpURL(url)) { - // invalid file--don't enable searching for it - return; - } - - String file = url.getFile(); - int separatorIndex = file.lastIndexOf(File.separator); - file = file.substring(separatorIndex + 1); - findDialog.setTitle(DIALOG_TITLE_PREFIX + file); - }); - // note: see HTMLEditorKit$LinkController.mouseMoved() for inspiration htmlEditorPane = getHTMLEditorPane(contentViewer); - TextComponentSearcher searcher = new TextComponentSearcher(htmlEditorPane); - findDialog = new FindDialog(DIALOG_TITLE_PREFIX, searcher); + HtmlTextSearcher searcher = new HtmlTextSearcher(htmlEditorPane); + findDialog = new FindDialog("Help Find", searcher); htmlEditorPane.addMouseListener(new MouseAdapter() { @Override @@ -93,14 +85,6 @@ class HelpViewSearcher { // highlighter.addHighlight(0, 0, null) } - private boolean isValidHelpURL(URL url) { - if (url == null) { - return false; - } - String file = url.getFile(); - return new File(file).exists(); - } - private void grabSearchEngine() { Enumeration navigators = jHelp.getHelpNavigators(); while (navigators.hasMoreElements()) { @@ -193,6 +177,156 @@ class HelpViewSearcher { } } + private class HtmlTextSearcher extends TextComponentSearcher { + + public HtmlTextSearcher(JEditorPane editorPane) { + super(editorPane); + } + + @Override + protected HtmlSearchResults createSearchResults( + Worker worker, JEditorPane jEditorPane, String searchText, + TreeMap matchesByPosition) { + + HtmlSearchResults results = new HtmlSearchResults(worker, jEditorPane, searchText, + matchesByPosition); + + TextHelpModel model = jHelp.getModel(); + URL url = model.getCurrentURL(); + results.setUrl(url); + return results; + } + } + + private class HtmlSearchResults extends TextComponentSearchResults { + + private PageLoadedListener pageLoadListener; + private URL searchUrl; + + // we use the document length to know when our page is finished loading on a reload + private int fullDocumentLength; + + private String name; + + HtmlSearchResults(Worker worker, JEditorPane editorPane, String searchText, + TreeMap matchesByPosition) { + super(worker, editorPane, searchText, matchesByPosition); + + pageLoadListener = new PageLoadedListener(this); + editorPane.addPropertyChangeListener("page", pageLoadListener); + + Document doc = editorPane.getDocument(); + fullDocumentLength = doc.getLength(); + } + + @Override + public String getName() { + return name; + } + + @Override + protected boolean isInvalid(String otherSearchText) { + if (!isMyHelpPageShowing()) { + return true; + } + return super.isInvalid(otherSearchText); + } + + private boolean isMyHelpPageShowing() { + TextHelpModel model = jHelp.getModel(); + URL htmlViewerURL = model.getCurrentURL(); + if (!Objects.equals(htmlViewerURL, searchUrl)) { + // the help does not have my page + return false; + } + + // The document gets loaded asynchronously. Use the length to know when it is finished + // loaded. This will not work correctly when the lengths are the same between two + // documents, but that should be a low occurrence event. + Document doc = editorPane.getDocument(); + int currentLength = doc.getLength(); + return fullDocumentLength == currentLength; + } + + private void loadMyHelpPage(TaskMonitor m) { + if (isMyHelpPageShowing()) { + return; // no need to reload + } + + // Trigger the URL of the results to load and then activate the results + jHelp.setCurrentURL(searchUrl); + } + + /** + * Start an asynchronous activation. When we activate, we have to tell the viewer to load a + * new html page, which is asynchronous. + * @return the future + */ + @Override + protected ActivationJob createActivationJob() { + + // start a new page load and then wait for it to finish + return (ActivationJob) super.createActivationJob() + .thenRun(this::loadMyHelpPage) + .thenWait(this::isMyHelpPageShowing, Duration.ofSeconds(3)); + } + + @Override + public void activate() { + // + // When we activate, a new page load may get triggered. When that happens the caret + // position will get moved by the help viewer. We will put back the last active search + // location after the load has finished. + // + SearchLocation lastActiveLocation = getActiveLocation(); + FindJob job = startActivation() + .thenRunSwing(() -> restoreLocation(lastActiveLocation)); + + runActivationJob((ActivationJob) job); + } + + private void restoreLocation(SearchLocation lastActiveLocation) { + if (lastActiveLocation != null) { + setActiveLocation(null); + setActiveLocation(lastActiveLocation); + } + } + + @Override + public void dispose() { + editorPane.removePropertyChangeListener("page", pageLoadListener); + super.dispose(); + } + + void setUrl(URL url) { + searchUrl = url; + name = getFilename(searchUrl); + } + + private URL getUrl() { + return searchUrl; + } + + private class PageLoadedListener implements PropertyChangeListener { + + private HtmlSearchResults htmlResults; + + PageLoadedListener(HtmlSearchResults htmlResults) { + this.htmlResults = htmlResults; + } + + @Override + public void propertyChange(PropertyChangeEvent evt) { + + URL newPage = (URL) evt.getNewValue(); + if (!Objects.equals(newPage, htmlResults.getUrl())) { + htmlResults.deactivate(); + } + } + + } + } + // // private class IndexerSearchTask extends Task { // diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialog.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialog.java index 6c06f0bcab..4d3dc86ebc 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialog.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialog.java @@ -23,10 +23,14 @@ import javax.swing.*; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; -import docking.ReusableDialogComponentProvider; +import org.apache.commons.lang3.StringUtils; + +import docking.*; import docking.widgets.button.GRadioButton; import docking.widgets.combobox.GhidraComboBox; import docking.widgets.label.GLabel; +import docking.widgets.search.FindDialogSearcher; +import docking.widgets.search.SearchResults; import utility.function.Callback; /** @@ -37,8 +41,12 @@ public class FindDialog extends ReusableDialogComponentProvider { protected GhidraComboBox comboBox; protected FindDialogSearcher searcher; + protected SearchResults searchResults; + private JButton nextButton; private JButton previousButton; + private JButton findAllButton; + private boolean isFindButtonApiDisabled; private JRadioButton stringRadioButton; private JRadioButton regexRadioButton; @@ -49,7 +57,14 @@ public class FindDialog extends ReusableDialogComponentProvider { this.searcher = searcher; addWorkPanel(buildMainPanel()); - buildButtons(); + buildFindButtons(); + + addDismissButton(); + } + + public void setFindAllEnabled(boolean enabled) { + isFindButtonApiDisabled = !enabled; + findAllButton.setEnabled(enabled); } @Override @@ -62,7 +77,7 @@ public class FindDialog extends ReusableDialogComponentProvider { this.closedCallback = Callback.dummyIfNull(c); } - private void buildButtons() { + protected void buildFindButtons() { nextButton = new JButton("Next"); nextButton.setMnemonic('N'); nextButton.getAccessibleContext().setAccessibleName("Next"); @@ -76,7 +91,13 @@ public class FindDialog extends ReusableDialogComponentProvider { previousButton.addActionListener(ev -> doSearch(false)); addButton(previousButton); - addDismissButton(); + findAllButton = new JButton("Find All"); + findAllButton.setMnemonic('A'); + findAllButton.getAccessibleContext().setAccessibleName("Find All"); + findAllButton.addActionListener(ev -> doSearchAll()); + addButton(findAllButton); + + enableButtons(false); } private JPanel buildMainPanel() { @@ -137,6 +158,10 @@ public class FindDialog extends ReusableDialogComponentProvider { protected void enableButtons(boolean b) { nextButton.setEnabled(b); previousButton.setEnabled(b); + + if (!isFindButtonApiDisabled) { + findAllButton.setEnabled(b); + } } private JPanel buildFormatPanel() { @@ -149,13 +174,6 @@ public class FindDialog extends ReusableDialogComponentProvider { return formatPanel; } - @Override - protected void dialogClosed() { - comboBox.setText(""); - searcher.clearHighlights(); - closedCallback.call(); - } - public void next() { doSearch(true); } @@ -168,7 +186,7 @@ public class FindDialog extends ReusableDialogComponentProvider { return regexRadioButton.isSelected(); } - private void doSearch(boolean forward) { + protected void doSearch(boolean forward) { if (!nextButton.isEnabled()) { return; // don't search while disabled @@ -179,14 +197,13 @@ public class FindDialog extends ReusableDialogComponentProvider { String searchText = comboBox.getText(); CursorPosition cursorPosition = searcher.getCursorPosition(); - SearchLocation searchLocation = - searcher.search(searchText, cursorPosition, forward, useRegex); + searchResults = searcher.search(searchText, cursorPosition, forward, useRegex); // // First, just search in the current direction. // - if (searchLocation != null) { - notifySearchHit(searchLocation); + if (searchResults != null) { + storeSearchText(searchText); return; } @@ -203,9 +220,9 @@ public class FindDialog extends ReusableDialogComponentProvider { cursorPosition = searcher.getEnd(); } - searchLocation = searcher.search(searchText, cursorPosition, forward, useRegex); - if (searchLocation != null) { - notifySearchHit(searchLocation); + searchResults = searcher.search(searchText, cursorPosition, forward, useRegex); + if (searchResults != null) { + storeSearchText(searchText); notifyUser(wrapMessage); return; } @@ -218,12 +235,6 @@ public class FindDialog extends ReusableDialogComponentProvider { notifyUser("Not found"); } - protected void notifySearchHit(SearchLocation location) { - searcher.setCursorPosition(location.getCursorPosition()); - storeSearchText(location.getSearchText()); - searcher.highlightSearchResults(location); - } - private void notifyUser(String message) { setStatus(message); @@ -236,11 +247,70 @@ public class FindDialog extends ReusableDialogComponentProvider { }); } + protected void doSearchAll() { + + DockingWindowManager dwm = DockingWindowManager.getActiveInstance(); + if (dwm == null) { + return; // not sure this can happen + } + + // Note: we do not save the SearchResults in this dialog. They will be managed by the + // provider we create below. This is in contrast to a single search, which will results. + // Further, when this method closes this dialog, the dialog's current results are cleared. + String searchText = getSearchText(); + SearchResults results = searcher.searchAll(searchText, useRegex()); + if (results.isEmpty()) { + setStatus("No results found"); + return; + } + + // save off searches that find results so users can reuse them later + storeSearchText(getSearchText()); + + String resultsName = results.getName(); + if (StringUtils.isBlank(resultsName)) { + resultsName = ""; + } + else { + resultsName = "[%s]".formatted(resultsName); + } + String dialogTitle = getTitle(); + + // e.g., Help Find: 'text' [Foo.html] + String subTitle = ": '%s' %s".formatted(searchText, resultsName); + Tool tool = dwm.getTool(); + FindDialogResultsProvider provider = + new FindDialogResultsProvider(tool, dialogTitle, subTitle, results); + + // set the tab text to the short and descriptive search term + provider.setTabText("'%s'".formatted(searchText)); + + close(); + } + + @Override + public void toFront() { + super.toFront(); + String text = comboBox.getText(); + enableButtons(text.length() != 0); + } + @Override protected void dialogShown() { clearStatusText(); } + @Override + protected void dialogClosed() { + comboBox.setText(""); + + if (searchResults != null) { + searchResults.dispose(); + searchResults = null; + } + closedCallback.call(); + } + public FindDialogSearcher getSearcher() { return searcher; } @@ -286,4 +356,5 @@ public class FindDialog extends ReusableDialogComponentProvider { // do this last since removing items may change the selected item model.setSelectedItem(text); } + } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialogResultsProvider.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialogResultsProvider.java new file mode 100644 index 0000000000..2644dd8b12 --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialogResultsProvider.java @@ -0,0 +1,342 @@ +/* ### + * 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.BorderLayout; +import java.awt.Component; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.*; +import javax.swing.event.TableModelEvent; +import javax.swing.event.TableModelListener; +import javax.swing.table.TableModel; + +import docking.ComponentProvider; +import docking.Tool; +import docking.action.DockingAction; +import docking.widgets.search.SearchLocationContext; +import docking.widgets.search.SearchResults; +import docking.widgets.table.*; +import docking.widgets.table.actions.DeleteTableRowAction; +import ghidra.docking.settings.Settings; +import ghidra.framework.plugintool.ServiceProvider; +import ghidra.framework.plugintool.ServiceProviderStub; +import ghidra.util.table.column.AbstractGColumnRenderer; +import ghidra.util.table.column.GColumnRenderer; + +public class FindDialogResultsProvider extends ComponentProvider + implements TableModelListener { + + private static final String OWNER_NAME = "Search"; + + private SearchResults searchResults; + + private JPanel componentPanel; + private FindResultsModel model; + private GTable table; + private GTableFilterPanel filterPanel; + + private DockingAction removeItemsAction; + + FindDialogResultsProvider(Tool tool, String title, String subTitle, + SearchResults searchResults) { + + super(tool, "Find All", OWNER_NAME); + this.searchResults = searchResults; + + this.model = new FindResultsModel(searchResults); + setTransient(); + setTitle(title + subTitle); + setSubTitle(subTitle); + setWindowMenuGroup(title); + + componentPanel = buildMainPanel(); + updateTitle(); + + addToTool(); + installRemoveItemsAction(); + + model.addTableModelListener(this); + + setVisible(true); + } + + private JPanel buildMainPanel() { + JPanel panel = new JPanel(new BorderLayout()); + + table = new GTable(model); + table.setHTMLRenderingEnabled(true); + filterPanel = new GTableFilterPanel<>(table, model); + table.getSelectionModel().addListSelectionListener(e -> { + if (e.getValueIsAdjusting()) { + return; + } + getTool().contextChanged(FindDialogResultsProvider.this); + }); + + table.setActionsEnabled(true); + + // add row listener to go to the field for that row when the user arrows + ListSelectionModel selectionModel = table.getSelectionModel(); + selectionModel.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + selectionModel.addListSelectionListener(lse -> { + if (lse.getValueIsAdjusting()) { + return; + } + + setSearchLocationFromRow(); + }); + + // this listener works around the case where the user clicks and already selected row + table.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() < 2) { + return; + } + + setSearchLocationFromRow(); + } + }); + + panel.add(new JScrollPane(table), BorderLayout.CENTER); + panel.add(filterPanel, BorderLayout.SOUTH); + + return panel; + } + + private void setSearchLocationFromRow() { + int row = table.getSelectedRow(); + if (row == -1) { + searchResults.setActiveLocation(null); + return; + } + + SearchLocation location = model.getRowObject(row); + searchResults.setActiveLocation(location); + } + + public void installRemoveItemsAction() { + if (removeItemsAction != null) { + return; + } + + removeItemsAction = new DeleteTableRowAction(table, OWNER_NAME) { + @Override + protected void removeSelectedItems() { + + int[] rows = table.getSelectedRows(); + List itemsToRemove = new ArrayList<>(); + for (int row : rows) { + itemsToRemove.add(model.getRowObject(row)); + } + + removeRowObjects(model, itemsToRemove); + + // put some selection back + int restoreRow = rows[0]; + selectRow(model, restoreRow); + } + + @Override + protected void removeRowObjects(TableModel tm, List itemsToRemove) { + model.remove(itemsToRemove); + } + }; + + getTool().addLocalAction(this, removeItemsAction); + } + + private String generateSubTitle() { + StringBuilder buffer = new StringBuilder(); + String filteredText = ""; + if (filterPanel.isFiltered()) { + filteredText = " of " + filterPanel.getUnfilteredRowCount(); + } + + int n = model.getRowCount(); + if (n == 1) { + buffer.append(" (1 entry").append(filteredText).append(")"); + } + else if (n > 1) { + buffer.append(" (").append(n).append(" entries").append(filteredText).append(")"); + } + return buffer.toString(); + } + + @Override + public void closeComponent() { + searchResults.dispose(); + + super.closeComponent(); + + filterPanel.dispose(); + } + + @Override + public JComponent getComponent() { + return componentPanel; + } + + @Override + public void componentActivated() { + searchResults.activate(); + + setSearchLocationFromRow(); + } + + @Override + public void componentDeactived() { + // We don't want this, as the user may wish to click around in the text pane and keep the + // highlights, which this call would break. + // searchResults.deactivate(); + } + + @Override + public void tableChanged(TableModelEvent ev) { + updateTitle(); + } + + private void updateTitle() { + setSubTitle(generateSubTitle()); + } + + public GTable getTable() { + return table; + } + + // for testing + public List getResults() { + return new ArrayList<>(model.getModelData()); + } + + private class FindResultsModel extends GDynamicColumnTableModel { + + private List data; + + FindResultsModel(SearchResults results) { + super(new ServiceProviderStub()); + this.data = results.getLocations(); + } + + void remove(List itemsToRemove) { + for (Object object : itemsToRemove) { + data.remove(object); + } + fireTableDataChanged(); + } + + @Override + public List getModelData() { + return data; + } + + @Override + protected TableColumnDescriptor createTableColumnDescriptor() { + + TableColumnDescriptor descriptor = + new TableColumnDescriptor<>(); + descriptor.addVisibleColumn(new LineNumberColumn(), 1, true); + descriptor.addVisibleColumn(new ContextColumn()); + return descriptor; + } + + @Override + public String getName() { + return "Find All Results"; + } + + @Override + public Object getDataSource() { + return null; + } + + private class LineNumberColumn + extends AbstractDynamicTableColumnStub { + + @Override + public Integer getValue(SearchLocation rowObject, Settings settings, + ServiceProvider sp) throws IllegalArgumentException { + return rowObject.getLineNumber(); + } + + @Override + public String getColumnName() { + return "Line"; + } + + @Override + public int getColumnPreferredWidth() { + return 75; + } + } + + private class ContextColumn extends + AbstractDynamicTableColumnStub { + + private GColumnRenderer renderer = new ContextCellRenderer(); + + @Override + public SearchLocationContext getValue(SearchLocation rowObject, + Settings settings, + ServiceProvider sp) throws IllegalArgumentException { + + SearchLocationContext context = rowObject.getContext(); + return context; + } + + @Override + public String getColumnName() { + return "Context"; + } + + @Override + public GColumnRenderer getColumnRenderer() { + return renderer; + } + + private class ContextCellRenderer + extends AbstractGColumnRenderer { + + { + // the context uses html + setHTMLRenderingEnabled(true); + } + + @Override + public Component getTableCellRendererComponent(GTableCellRenderingData cellData) { + + // initialize + super.getTableCellRendererComponent(cellData); + + SearchLocation match = (SearchLocation) cellData.getRowObject(); + SearchLocationContext context = match.getContext(); + String text = context.getBoldMatchingText(); + setText(text); + return this; + } + + @Override + public String getFilterString(SearchLocationContext context, Settings settings) { + return context.getPlainText(); + } + } + } + } +} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/SearchLocation.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/SearchLocation.java index 064f241389..c5f1388e86 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/SearchLocation.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/SearchLocation.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. @@ -15,32 +15,47 @@ */ package docking.widgets; +import docking.widgets.search.SearchLocationContext; + /** * An object that describes a search result. */ public class SearchLocation { private final int startIndexInclusive; private final int endIndexInclusive; - private final String searchText; - private final boolean forwardDirection; + private final String text; + private SearchLocationContext context; + private int lineNumber; - public SearchLocation(int startIndexInclusive, int endIndexInclusive, String searchText, - boolean forwardDirection) { + public SearchLocation(int startIndexInclusive, int endIndexInclusive, String text) { this.startIndexInclusive = startIndexInclusive; this.endIndexInclusive = endIndexInclusive; - this.searchText = searchText; - this.forwardDirection = forwardDirection; + this.text = text; + } + + public SearchLocation(int startIndexInclusive, int endIndexInclusive, String text, + int lineNumber, SearchLocationContext context) { + + this.startIndexInclusive = startIndexInclusive; + this.endIndexInclusive = endIndexInclusive; + this.text = text; + this.context = context; + this.lineNumber = lineNumber; + } + + public SearchLocationContext getContext() { + return context; + } + + public int getLineNumber() { + return lineNumber; } public CursorPosition getCursorPosition() { return new CursorPosition(startIndexInclusive); } - public String getSearchText() { - return searchText; - } - public int getEndIndexInclusive() { return endIndexInclusive; } @@ -49,20 +64,21 @@ public class SearchLocation { return startIndexInclusive; } + public boolean contains(int pos) { + return startIndexInclusive <= pos && endIndexInclusive >= pos; + } + public int getMatchLength() { return endIndexInclusive - startIndexInclusive + 1; } - public boolean isForwardDirection() { - return forwardDirection; - } - @Override public String toString() { - return searchText + "[" + fieldsToString() + "]"; + return text + "[" + fieldsToString() + "]"; } protected String fieldsToString() { - return startIndexInclusive + ", end=" + endIndexInclusive; + return "line=%s, start=%s, end=%s".formatted(lineNumber, startIndexInclusive, + endIndexInclusive); } } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/TextComponentSearcher.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/TextComponentSearcher.java deleted file mode 100644 index 76c9adcd68..0000000000 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/TextComponentSearcher.java +++ /dev/null @@ -1,539 +0,0 @@ -/* ### - * 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.*; -import java.util.Collection; -import java.util.Map.Entry; -import java.util.TreeMap; -import java.util.regex.*; - -import javax.swing.JEditorPane; -import javax.swing.event.*; -import javax.swing.text.*; - -import generic.theme.GColor; -import ghidra.util.Msg; -import ghidra.util.UserSearchUtils; -import ghidra.util.exception.CancelledException; -import ghidra.util.task.*; - -/** - * A class to find text matches in the given {@link TextComponent}. This class will search for all - * matches and cache the results for future requests when the user presses Next or Previous. All - * matches will be highlighted in the text component. The match containing the cursor will be a - * different highlight color than the others. When the find dialog is closed, all highlights are - * removed. - */ -public class TextComponentSearcher implements FindDialogSearcher { - - private Color highlightColor = new GColor("color.bg.find.highlight"); - private Color activeHighlightColor = new GColor("color.bg.find.highlight.active"); - - private JEditorPane editorPane; - private DocumentListener documentListener = new DocumentChangeListener(); - - private CaretListener caretListener = new CaretChangeListener(); - private SwingUpdateManager caretUpdater = new SwingUpdateManager(() -> updateActiveHighlight()); - private volatile boolean isUpdatingCaretInternally; - - private SearchResults searchResults; - - public TextComponentSearcher(JEditorPane editorPane) { - this.editorPane = editorPane; - - if (editorPane == null) { - return; // some clients initialize without an editor pane - } - - Document document = editorPane.getDocument(); - document.addDocumentListener(documentListener); - - editorPane.addCaretListener(caretListener); - } - - public void setEditorPane(JEditorPane editorPane) { - if (this.editorPane != editorPane) { - Document document = editorPane.getDocument(); - document.removeDocumentListener(documentListener); - markResultsStale(); - } - this.editorPane = editorPane; - } - - public JEditorPane getEditorPane() { - return editorPane; - } - - @Override - public void dispose() { - caretUpdater.dispose(); - - if (editorPane != null) { - Document document = editorPane.getDocument(); - document.removeDocumentListener(documentListener); - - clearHighlights(); - } - } - - @Override - public void clearHighlights() { - if (searchResults != null) { - searchResults.removeHighlights(); - searchResults = null; - } - } - - public boolean hasSearchResults() { - return searchResults != null && !searchResults.isEmpty(); - } - - public boolean isStale() { - return searchResults != null && searchResults.isStale(); - } - - private void markResultsStale() { - if (searchResults != null) { - searchResults.setStale(); - } - } - - private void updateActiveHighlight() { - if (searchResults == null) { - return; - } - - int pos = editorPane.getCaretPosition(); - searchResults.updateActiveMatch(pos); - } - - private void setCaretPositionInternally(int pos) { - isUpdatingCaretInternally = true; - try { - editorPane.setCaretPosition(pos); - } - finally { - isUpdatingCaretInternally = false; - } - } - - @Override - public CursorPosition getCursorPosition() { - int pos = editorPane.getCaretPosition(); - return new CursorPosition(pos); - } - - @Override - public void setCursorPosition(CursorPosition position) { - int pos = position.getPosition(); - editorPane.setCaretPosition(pos); - } - - @Override - public CursorPosition getStart() { - return new CursorPosition(0); - } - - @Override - public CursorPosition getEnd() { - int length = editorPane.getDocument().getLength(); - return new CursorPosition(length - 1); - } - - @Override - public void highlightSearchResults(SearchLocation location) { - - if (location == null) { - clearHighlights(); - return; - } - - TextComponentSearchLocation textLocation = (TextComponentSearchLocation) location; - FindMatch match = textLocation.getMatch(); - searchResults.setActiveMatch(match); - } - - @Override - public SearchLocation search(String text, CursorPosition cursorPosition, - boolean searchForward, boolean useRegex) { - - updateSearchResults(text, useRegex); - - int pos = cursorPosition.getPosition(); - int searchStart = getSearchStart(pos, searchForward); - - FindMatch match = searchResults.getNextMatch(searchStart, searchForward); - if (match == null) { - return null; - } - - return new TextComponentSearchLocation(match.getStart(), match.getEnd(), text, - searchForward, match); - } - - private void updateSearchResults(String text, boolean useRegex) { - if (searchResults != null) { - if (!searchResults.isInvalid(text)) { - return; // the current results are still valid - } - - searchResults.removeHighlights(); - } - - SearchTask searchTask = new SearchTask(text, useRegex); - TaskLauncher.launch(searchTask); - searchResults = searchTask.getSearchResults(); - searchResults.applyHighlights(); - } - - private int getSearchStart(int startPosition, boolean isForward) { - - FindMatch activeMatch = searchResults.getActiveMatch(); - if (activeMatch == null) { - return startPosition; - } - - int lastMatchStart = activeMatch.getStart(); - if (startPosition != lastMatchStart) { - return startPosition; - } - - // Always prefer the caret position, unless it aligns with the previous match. By - // moving it forward one we will continue our search, as opposed to always matching - // the same hit. - if (isForward) { - return startPosition + 1; - } - - // backwards - if (startPosition == 0) { - return editorPane.getText().length(); - } - return startPosition - 1; - } - -//================================================================================================= -// Inner Classes -//================================================================================================= - - private class SearchResults { - - private TreeMap matchesByPosition; - private FindMatch activeMatch; - private boolean isStale; - private String searchText; - - SearchResults(String searchText, TreeMap matchesByPosition) { - this.searchText = searchText; - this.matchesByPosition = matchesByPosition; - } - - boolean isStale() { - return isStale; - } - - void updateActiveMatch(int pos) { - if (activeMatch != null) { - activeMatch.setActive(false); - activeMatch = null; - } - - if (isStale) { - // not way to easily change highlights for the caret position while we are stale, - // since the matches no longer match the document positions - return; - } - - for (FindMatch match : matchesByPosition.values()) { - boolean isActive = false; - if (match.contains(pos)) { - activeMatch = match; - isActive = true; - } - match.setActive(isActive); - } - } - - FindMatch getActiveMatch() { - return activeMatch; - } - - FindMatch getNextMatch(int searchStart, boolean searchForward) { - - Entry entry; - if (searchForward) { - entry = matchesByPosition.ceilingEntry(searchStart); - } - else { - entry = matchesByPosition.floorEntry(searchStart); - } - - if (entry == null) { - return null; // no more matches in the current direction - } - - return entry.getValue(); - } - - boolean isEmpty() { - return matchesByPosition.isEmpty(); - } - - void setStale() { - isStale = true; - } - - boolean isInvalid(String otherSearchText) { - if (isStale) { - return true; - } - return !searchText.equals(otherSearchText); - } - - void setActiveMatch(FindMatch match) { - if (activeMatch != null) { - activeMatch.setActive(false); - } - - activeMatch = match; - activeMatch.activate(); - } - - void applyHighlights() { - Collection matches = matchesByPosition.values(); - for (FindMatch match : matches) { - match.applyHighlight(); - } - } - - void removeHighlights() { - - activeMatch = null; - - JEditorPane editor = editorPane; - Highlighter highlighter = editor.getHighlighter(); - if (highlighter != null) { - highlighter.removeAllHighlights(); - } - - matchesByPosition.clear(); - } - } - - private class TextComponentSearchLocation extends SearchLocation { - - private FindMatch match; - - public TextComponentSearchLocation(int start, int end, - String searchText, boolean forwardDirection, FindMatch match) { - super(start, end, searchText, forwardDirection); - this.match = match; - } - - FindMatch getMatch() { - return match; - } - } - - private class SearchTask extends Task { - - private String searchText; - private TreeMap searchHits = new TreeMap<>(); - private boolean useRegex; - - SearchTask(String searchText, boolean useRegex) { - super("Help Search Task", true, false, true, true); - this.searchText = searchText; - this.useRegex = useRegex; - } - - @Override - public void run(TaskMonitor monitor) throws CancelledException { - - String screenText; - try { - Document document = editorPane.getDocument(); - screenText = document.getText(0, document.getLength()); - } - catch (BadLocationException e) { - Msg.error(this, "Unable to get text for user find operation", e); - return; - } - - Pattern pattern = createSearchPattern(searchText, useRegex); - Matcher matcher = pattern.matcher(screenText); - while (matcher.find()) { - monitor.checkCancelled(); - int start = matcher.start(); - int end = matcher.end(); - FindMatch match = new FindMatch(searchText, start, end); - searchHits.put(start, match); - } - - } - - private Pattern createSearchPattern(String searchString, boolean isRegex) { - - int options = Pattern.CASE_INSENSITIVE | Pattern.DOTALL; - if (isRegex) { - try { - return Pattern.compile(searchString, options); - } - catch (PatternSyntaxException e) { - Msg.showError(this, editorPane, "Regular Expression Syntax Error", - e.getMessage()); - return null; - } - } - - return UserSearchUtils.createPattern(searchString, false, options); - } - - SearchResults getSearchResults() { - return new SearchResults(searchText, searchHits); - } - } - - private class FindMatch { - - private String text; - private int start; - private int end; - private boolean isActive; - - // this tag is a way to remove an installed highlight - private Object lastHighlightTag; - - FindMatch(String text, int start, int end) { - this.start = start; - this.end = end; - this.text = text; - } - - boolean contains(int pos) { - // exclusive of end so the cursor behind the match does is not in the highlight - return start <= pos && pos < end; - } - - /** Calls setActive() and moves the caret position */ - void activate() { - setActive(true); - setCaretPositionInternally(start); - scrollToVisible(); - } - - /** - * Makes this match active and updates the highlight color - * @param b true for active - */ - void setActive(boolean b) { - isActive = b; - applyHighlight(); - } - - int getStart() { - return start; - } - - int getEnd() { - return end; - } - - void scrollToVisible() { - - try { - Rectangle startR = editorPane.modelToView2D(start).getBounds(); - Rectangle endR = editorPane.modelToView2D(end).getBounds(); - endR.width += 20; // a little extra space so the view is not right at the text end - Rectangle union = startR.union(endR); - editorPane.scrollRectToVisible(union); - } - catch (BadLocationException e) { - Msg.debug(this, "Exception scrolling to text", e); - } - } - - @Override - public String toString() { - return "[" + start + ',' + end + "] " + text; - } - - void applyHighlight() { - Highlighter highlighter = editorPane.getHighlighter(); - if (highlighter == null) { - highlighter = new DefaultHighlighter(); - editorPane.setHighlighter(highlighter); - } - - Highlighter.HighlightPainter painter = - new DefaultHighlighter.DefaultHighlightPainter( - isActive ? activeHighlightColor : highlightColor); - - try { - - if (lastHighlightTag != null) { - highlighter.removeHighlight(lastHighlightTag); - } - - lastHighlightTag = highlighter.addHighlight(start, end, painter); - } - catch (BadLocationException e) { - Msg.debug(this, "Exception adding highlight", e); - } - } - } - - private class DocumentChangeListener implements DocumentListener { - - @Override - public void insertUpdate(DocumentEvent e) { - // this allows the previous search results to stay visible until a new find is requested - markResultsStale(); - } - - @Override - public void removeUpdate(DocumentEvent e) { - markResultsStale(); - } - - @Override - public void changedUpdate(DocumentEvent e) { - // ignore attribute changes since they don't affect the text content - } - } - - private class CaretChangeListener implements CaretListener { - - private int lastPos = -1; - - @Override - public void caretUpdate(CaretEvent e) { - int pos = e.getDot(); - if (isUpdatingCaretInternally) { - lastPos = pos; - return; - } - - if (pos == lastPos) { - return; - } - lastPos = pos; - caretUpdater.update(); - } - - } -} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialogSearcher.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/FindDialogSearcher.java similarity index 68% rename from Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialogSearcher.java rename to Ghidra/Framework/Docking/src/main/java/docking/widgets/search/FindDialogSearcher.java index 8e544e8cbe..92c9fc44a7 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/FindDialogSearcher.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/FindDialogSearcher.java @@ -13,11 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package docking.widgets; +package docking.widgets.search; -import java.util.List; - -import javax.help.UnsupportedOperationException; +import docking.widgets.CursorPosition; +import docking.widgets.FindDialog; /** * A simple interface for the {@link FindDialog} so that it can work for different search clients. @@ -25,6 +24,10 @@ import javax.help.UnsupportedOperationException; * The {@link CursorPosition} object used by this interface is one that implementations can extend * to add extra context to use when searching. The implementation is responsible for creating the * locations and these locations will later be handed back to the searcher. + *

+ * The {@link FindDialog} should use a single searcher for the life of the dialog. This allows all + * search results generated by that dialog to share the same worker queue for running background + * operations related to managing search results. */ public interface FindDialogSearcher { @@ -34,12 +37,6 @@ public interface FindDialogSearcher { */ public CursorPosition getCursorPosition(); - /** - * Sets the cursor position after a successful search. - * @param position the cursor position. - */ - public void setCursorPosition(CursorPosition position); - /** * Returns the start cursor position. This is used when a search is wrapped to start at the * beginning of the search range. @@ -54,45 +51,28 @@ public interface FindDialogSearcher { */ public CursorPosition getEnd(); - /** - * Called to signal the implementor should highlight the given search location. - * @param location the search result location. - */ - public void highlightSearchResults(SearchLocation location); - - /** - * Clears any active highlights. - */ - public void clearHighlights(); - /** * Perform a search for the next item in the given direction starting at the given cursor * position. * @param text the search text. * @param cursorPosition the current cursor position. * @param searchForward true if searching forward. - * @param useRegex true if the search text is a regular expression; false if the texts is - * literal text. + * @param useRegex true if the search text is a regular expression; false if the text is literal. * @return the search result or null if no match was found. */ - public SearchLocation search(String text, CursorPosition cursorPosition, boolean searchForward, + public SearchResults search(String text, CursorPosition cursorPosition, boolean searchForward, boolean useRegex); /** * Search for all matches. * @param text the search text. - * @param useRegex true if the search text is a regular expression; false if the texts is - * literal text. + * @param useRegex true if the search text is a regular expression; false if the text is literal. * @return all search results or an empty list. */ - public default List searchAll(String text, boolean useRegex) { - throw new UnsupportedOperationException("Search All is not defined for this searcher"); - } + public SearchResults searchAll(String text, boolean useRegex); /** - * Disposes this searcher. This does nothing by default. + * Disposes this searcher. */ - public default void dispose() { - // stub - } + public void dispose(); } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferenceContext.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchLocationContext.java similarity index 85% rename from Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferenceContext.java rename to Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchLocationContext.java index 1784d1884e..310be816a9 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferenceContext.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchLocationContext.java @@ -13,25 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package ghidra.app.plugin.core.navigation.locationreferences; +package docking.widgets.search; import java.util.*; +import docking.widgets.SearchLocation; import generic.json.Json; import ghidra.util.HTMLUtilities; /** - * A class to hold context representation for {@link LocationReference}s. + * A class to hold context representation for {@link SearchLocation}s. * - * @see LocationReferenceContextBuilder + * @see SearchLocationContextBuilder */ -public class LocationReferenceContext { +public class SearchLocationContext { private static final String EMBOLDEN_START = ""; private static final String EMBOLDEN_END = ""; - public static final LocationReferenceContext EMPTY_CONTEXT = new LocationReferenceContext(); + public static final SearchLocationContext EMPTY_CONTEXT = new SearchLocationContext(); private final List parts; @@ -42,8 +43,8 @@ public class LocationReferenceContext { * @param text the text * @return the context */ - public static LocationReferenceContext get(String text) { - return text == null ? EMPTY_CONTEXT : new LocationReferenceContext(text); + public static SearchLocationContext get(String text) { + return text == null ? EMPTY_CONTEXT : new SearchLocationContext(text); } /** @@ -51,14 +52,14 @@ public class LocationReferenceContext { * @param context the context to verify is not null * @return the given context or the {@link #EMPTY_CONTEXT} if the given context is null */ - public static LocationReferenceContext get(LocationReferenceContext context) { + public static SearchLocationContext get(SearchLocationContext context) { return context == null ? EMPTY_CONTEXT : context; } /** * Creates an empty context object */ - private LocationReferenceContext() { + private SearchLocationContext() { this.parts = List.of(new BasicPart("")); } @@ -66,7 +67,7 @@ public class LocationReferenceContext { * Creates a context with the raw and decorated context being the same. * @param context the context; cannot be null */ - private LocationReferenceContext(String context) { + private SearchLocationContext(String context) { Objects.requireNonNull(context); this.parts = List.of(new BasicPart(context)); } @@ -74,9 +75,9 @@ public class LocationReferenceContext { /** * Constructor used to create this context by providing the given text parts * @param parts the parts - * @see LocationReferenceContextBuilder + * @see SearchLocationContextBuilder */ - LocationReferenceContext(List parts) { + SearchLocationContext(List parts) { this.parts = parts; } @@ -120,7 +121,7 @@ public class LocationReferenceContext { /** * Returns any sub-strings of this context's overall text that match client-defined input * - * See the {@link LocationReferenceContextBuilder} for how to define matching text pieces + * See the {@link SearchLocationContextBuilder} for how to define matching text pieces * @return the matching strings */ public List getMatches() { diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferenceContextBuilder.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchLocationContextBuilder.java similarity index 72% rename from Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferenceContextBuilder.java rename to Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchLocationContextBuilder.java index a99a9d0b9f..c550f919d9 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/navigation/locationreferences/LocationReferenceContextBuilder.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchLocationContextBuilder.java @@ -13,20 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package ghidra.app.plugin.core.navigation.locationreferences; +package docking.widgets.search; import java.util.ArrayList; import java.util.List; +import docking.widgets.search.SearchLocationContext.*; import generic.json.Json; -import ghidra.app.plugin.core.navigation.locationreferences.LocationReferenceContext.*; /** - * A builder for {@link LocationReferenceContext} objects. Use {@link #append(String)} for normal + * A builder for {@link SearchLocationContext} objects. Use {@link #append(String)} for normal * text pieces. Use {@link #appendMatch(String)} for text that is meant to be rendered specially * by the context class. */ -public class LocationReferenceContextBuilder { +public class SearchLocationContextBuilder { private List parts = new ArrayList<>(); @@ -35,7 +35,7 @@ public class LocationReferenceContextBuilder { * @param text the text * @return this builder */ - public LocationReferenceContextBuilder append(String text) { + public SearchLocationContextBuilder append(String text) { if (text == null) { text = ""; } @@ -45,12 +45,12 @@ public class LocationReferenceContextBuilder { /** * Appends the given text to this builder. This text represents a client-defined 'match' that - * will be rendered with markup when {@link LocationReferenceContext#getBoldMatchingText()} is + * will be rendered with markup when {@link SearchLocationContext#getBoldMatchingText()} is * called. * @param text the text * @return this builder */ - public LocationReferenceContextBuilder appendMatch(String text) { + public SearchLocationContextBuilder appendMatch(String text) { if (text == null) { throw new NullPointerException("Match text cannot be null"); } @@ -62,7 +62,7 @@ public class LocationReferenceContextBuilder { * Adds a newline character to the previously added text. * @return this builder */ - public LocationReferenceContextBuilder newline() { + public SearchLocationContextBuilder newline() { if (parts.isEmpty()) { throw new IllegalStateException("Cannot add a newline without first appending text"); } @@ -72,12 +72,12 @@ public class LocationReferenceContextBuilder { } /** - * Builds a {@link LocationReferenceContext} using the text supplied via the {@code append} + * Builds a {@link SearchLocationContext} using the text supplied via the {@code append} * methods. * @return the context */ - public LocationReferenceContext build() { - return new LocationReferenceContext(parts); + public SearchLocationContext build() { + return new SearchLocationContext(parts); } /** diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchResults.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchResults.java new file mode 100644 index 0000000000..c451fc2614 --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/SearchResults.java @@ -0,0 +1,302 @@ +/* ### + * 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.search; + +import java.net.URL; +import java.time.Duration; +import java.util.List; +import java.util.function.BooleanSupplier; + +import docking.widgets.FindDialog; +import docking.widgets.SearchLocation; +import ghidra.util.Msg; +import ghidra.util.Swing; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.MonitoredRunnable; +import ghidra.util.task.TaskMonitor; +import ghidra.util.worker.Job; +import ghidra.util.worker.Worker; + +/** + * A collection of {@link SearchLocation}s created when the user has performed a find operation on + * the {@link FindDialog}. The dialog will find all results and then use the results to move to the + * next and previous locations as requested. The user may also choose to show all results in a + * table. + *

+ * The searcher uses a worker queue to manage activating and deactivating highlights, which may + * require reload operations on the originally searched text. + */ +public abstract class SearchResults { + + private Worker worker; + + protected SearchResults(Worker worker) { + this.worker = worker; + } + + Worker getWorker() { + return worker; + } + +//================================================================================================= +// Abstract Methods +//================================================================================================= + + /** + * Returns the name of this set of search results. This is a short description, such as a + * filename or function name. This should be null for text components that do not change + * contents based on some external source of data, such as a file. + * @return the name or null + */ + public abstract String getName(); + + /** + * Activates this set of search results. This will restore highlights to the source of the + * search. + */ + public abstract void activate(); + + /** + * Deactivates this set of search results. This will clear this results' highlights from the + * source of the search. + */ + public abstract void deactivate(); + + /** + * Sets the active location, which will be highlighted differently than the other search + * matches. This method will ensure that this search results object is active (see + * {@link #activate()}. This method will also move the cursor to the given location. + * + * @param location the location + */ + public abstract void setActiveLocation(SearchLocation location); + + /** + * {@return the active search location or null. The active location is typically the search + * location that contains the user's cursor.} + */ + public abstract SearchLocation getActiveLocation(); + + /** + * Returns all search locations in this set of search results + * @return the location + */ + public abstract List getLocations(); + + public abstract boolean isEmpty(); + + public abstract void dispose(); + +//================================================================================================= +// End Abstract Methods +//================================================================================================= + + protected String getFilename(URL url) { + if (url == null) { + return null; + } + + String path = url.getPath(); + int index = path.lastIndexOf('/'); + if (index < 0) { + return null; + } + return path.substring(index + 1); // +1 to not get the slash + } + + /** + * Clears all jobs that have the same class as the given job. Clients can call this method + * before submitting the given job to clear any other instances of that job type before running. + * @param job the job + */ + protected void cancelAllJobsOfType(FindJob job) { + Class clazz = job.getClass(); + worker.clearAllJobs(j -> j.getClass() == clazz); + } + + /** + * Runs the given activation job. This class will cancel any existing activation jobs with the + * assumption that only one activation should be taking place at any given time. This is useful + * since activations may be slow. + * @param job the job + */ + protected void runActivationJob(ActivationJob job) { + cancelAllJobsOfType(job); + runJob(job); + } + + /** + * Runs the given job, cancelling any currently running jobs. We assume that only one job + * should be run at a time for the given worker for all search results sharing that worker, + * not just a single search results. This keeps multiple search results from interfering with + * each other as the user interacts with the results. + * @param job the job + */ + protected void runJob(FindJob job) { + worker.schedule(job); + } + + /** + * A worker {@link Job} that allows subclasses to add follow-on jobs to be performed as long + * as the work is not cancelled. + */ + protected class FindJob extends Job { + + // The parent job in a chain of jobs. Useful for debugging. + protected FindJob parent; + + // optional follow-on job + protected FindJob nextJob; + + // optional runnable to be called instead of doRun() + protected MonitoredRunnable runnable; + + public FindJob() { + // no runnable; use doRun() + } + + public FindJob(FindJob parent) { + this.parent = parent; + } + + public FindJob(FindJob parent, MonitoredRunnable r) { + this.parent = parent; + this.runnable = r; + } + + @Override + public final void run(TaskMonitor monitor) throws CancelledException { + + monitor.checkCancelled(); + + if (runnable != null) { + runnable.monitoredRun(monitor); + } + else { + doRun(monitor); + } + + monitor.checkCancelled(); + + if (nextJob != null) { + nextJob.run(monitor); + } + } + + @SuppressWarnings("unused") // we don't use the cancel, but subclasses may + protected void doRun(TaskMonitor monitor) throws CancelledException { + // clients override to do background work + } + + public FindJob thenRun(MonitoredRunnable r) { + FindJob job = new FindJob(this, r); + setNextJob(job); + return this; + } + + public FindJob thenWait(BooleanSupplier waitFor, Duration maxWaitTime) { + FindJob job = new WaitJob(this, waitFor, maxWaitTime); + setNextJob(job); + return this; + } + + public FindJob thenRunSwing(Runnable r) { + MonitoredRunnable swingRunnable = m -> { + Swing.runNow(() -> { + if (m.isCancelled()) { + return; + } + r.run(); + }); + }; + + FindJob job = new FindJob(this, swingRunnable); + setNextJob(job); + + return this; + } + + private void setNextJob(FindJob job) { + if (nextJob == null) { + nextJob = job; + } + else { + nextJob.setNextJob(job); + } + } + + @Override + public String toString() { + String base = getClass().getSimpleName() + ' ' + SearchResults.this; + if (runnable != null) { + return "runnable-only " + base; + } + return base; + } + } + + public class ActivationJob extends FindJob { + // nothing special to do here; just a marker class + } + + public class SwingJob extends FindJob { + public SwingJob(Runnable r) { + this.runnable = m -> r.run(); + } + } + + private class WaitJob extends FindJob { + private BooleanSupplier waitFor; + private Duration maxWaitTime; + + protected WaitJob(FindJob parent, BooleanSupplier waitFor, Duration maxWaitTime) { + super(parent); + this.waitFor = waitFor; + this.maxWaitTime = maxWaitTime; + } + + @Override + protected void doRun(TaskMonitor monitor) throws CancelledException { + int sleepyTime = 250; + int totalMs = 0; + while (totalMs < maxWaitTime.toMillis()) { + + monitor.checkCancelled(); + + if (waitFor.getAsBoolean()) { + return; + } + + totalMs += sleepyTime; + sleep(sleepyTime); + } + + monitor.cancel(); + throw new CancelledException(); + } + + private void sleep(int sleepyTime) throws CancelledException { + + try { + Thread.sleep(sleepyTime); + } + catch (InterruptedException e) { + Msg.debug(this, "Find job interrupted while waiting"); + throw new CancelledException(); + } + } + } +} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearchLocation.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearchLocation.java new file mode 100644 index 0000000000..150e103322 --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearchLocation.java @@ -0,0 +1,47 @@ +/* ### + * 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.search; + +import docking.widgets.SearchLocation; + +public class TextComponentSearchLocation extends SearchLocation { + + private boolean isActive; + private Object lastHighlightTag; + + TextComponentSearchLocation(String searchText, int startInclusive, int endInclusive, + int lineNumber, SearchLocationContext context) { + super(startInclusive, endInclusive, searchText, lineNumber, context); + } + + void setActive(boolean b) { + isActive = b; + } + + boolean isActive() { + return isActive; + } + + // sets the Highlighter created that allows for removal of the highlight for this match + void setHighlightTag(Object tag) { + this.lastHighlightTag = tag; + } + + Object getHighlightTag() { + return lastHighlightTag; + } + +} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearchResults.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearchResults.java new file mode 100644 index 0000000000..1acd515183 --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearchResults.java @@ -0,0 +1,616 @@ +/* ### + * 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.search; + +import java.awt.*; +import java.net.URL; +import java.util.*; +import java.util.List; +import java.util.Map.Entry; + +import javax.swing.JEditorPane; +import javax.swing.event.*; +import javax.swing.text.*; +import javax.swing.text.DefaultHighlighter.DefaultHighlightPainter; +import javax.swing.text.Highlighter.Highlight; +import javax.swing.text.Highlighter.HighlightPainter; + +import docking.widgets.SearchLocation; +import generic.theme.GColor; +import ghidra.util.Msg; +import ghidra.util.Swing; +import ghidra.util.task.SwingUpdateManager; +import ghidra.util.worker.Worker; +import util.CollectionUtils; + +public class TextComponentSearchResults extends SearchResults { + + private Color highlightColor = new GColor("color.bg.find.highlight"); + private Color activeHighlightColor = new GColor("color.bg.find.highlight.active"); + + protected JEditorPane editorPane; + private SearchResultsHighlighterWrapper highlighter; + private DocumentListener documentListener = new DocumentChangeListener(); + private CaretListener caretListener = new CaretChangeListener(); + private SwingUpdateManager caretUpdater = + new SwingUpdateManager(() -> setActiveHighlightBasedOnCaret()); + private boolean isUpdatingCaretInternally; + + private String name; + private List searchLocations; + private TreeMap matchesByPosition; + private String searchText; + private TextComponentSearchLocation activeLocation; + + /** + * Stale means the document has changed and our location offsets may no longer match. Once + * stale, always stale. + */ + private boolean isStale; + + protected TextComponentSearchResults(Worker worker, JEditorPane editorPane, String searchText, + TreeMap matchesByPosition) { + super(worker); + this.editorPane = editorPane; + this.searchText = searchText; + this.matchesByPosition = matchesByPosition; + + URL url = editorPane.getPage(); + this.name = getFilename(url); + + Collection matches = matchesByPosition.values(); + this.searchLocations = new ArrayList<>(matches); + + Document document = editorPane.getDocument(); + document.addDocumentListener(documentListener); + + editorPane.addCaretListener(caretListener); + + // All results will be highlighted. Since we don't move the caret to a specific match, make + // sure that the highlight color gets updated based on the current caret position. + caretUpdater.updateLater(); + + highlighter = createHighlighter(); + } + + @Override + public String getName() { + return name; + } + + @Override + public void deactivate() { + if (isActive()) { + FindJob job = new SwingJob(this::unapplyHighlights); + runJob(job); + } + } + + /** + * Triggers the potentially asynchronous activation of this set of search results. When that is + * finished, we then restore our highlights. This is needed in the case that the implementor + * is using a document that does not match our search results. Some subclasses use + * asynchronous loading of their document. + */ + @Override + public void activate() { + FindJob job = startActivation(); + runJob(job); + } + + @Override + public void setActiveLocation(SearchLocation location) { + if (isStale) { + return; + } + + if (activeLocation == location) { + return; + } + + if (location == null) { + // no need to activate these results when clearing the active location + Swing.runNow(() -> doSetActiveLocation(null)); + return; + } + + FindJob job = startActivation().thenRunSwing(() -> doSetActiveLocation(location)); + runActivationJob((ActivationJob) job); + } + + /** + * Create a job to perform activation for this class. The activation job may be a 'done' job + * if not activation is required. + * @return the job + */ + protected ActivationJob startActivation() { + if (isActive()) { + return createFinishedActivationJob(); + } + + if (isStale) { + unapplyHighlights(); + return createFinishedActivationJob(); + } + + return (ActivationJob) createActivationJob().thenRunSwing(() -> applyHighlights()); + } + + /** + * Starts the job that will activate this class. Subclasses can override this method to change + * the job that gets run. + * @return the job + */ + protected ActivationJob createActivationJob() { + return new ActivationJob(); + } + + protected ActivationJob createFinishedActivationJob() { + return new ActivationJob(); + } + + private void doSetActiveLocation(SearchLocation newLocation) { + TextComponentSearchLocation oldLocation = activeLocation; + activeLocation = (TextComponentSearchLocation) newLocation; + if (oldLocation == newLocation) { + scrollToLocation(activeLocation); // this handles null + return; + } + + changeActiveLocation(oldLocation, activeLocation); + } + + TextComponentSearchLocation getNextLocation(int searchStart, boolean searchForward) { + + Entry entry; + if (searchForward) { + entry = matchesByPosition.ceilingEntry(searchStart); + } + else { + entry = matchesByPosition.floorEntry(searchStart); + } + + if (entry == null) { + return null; // no more matches in the current direction + } + + return entry.getValue(); + } + + @Override + public boolean isEmpty() { + return searchLocations.isEmpty(); + } + + @Override + public List getLocations() { + return CollectionUtils.asList(searchLocations, SearchLocation.class); + } + + boolean isStale() { + return isStale; + } + + void setStale() { + isStale = true; + unapplyHighlights(); + } + + protected boolean isInvalid(String otherSearchText) { + if (isStale) { + return true; + } + return !searchText.equals(otherSearchText); + } + + @Override + public SearchLocation getActiveLocation() { + return activeLocation; + } + + private void updateActiveLocationForCaretChange(int caret) { + TextComponentSearchLocation location = getLocation(caret); + setActiveLocation(location); + } + + private TextComponentSearchLocation getLocation(int caret) { + Optional optional = + searchLocations.stream().filter(l -> l.contains(caret)).findFirst(); + return optional.orElseGet(() -> null); + } + + private void changeActiveLocation(TextComponentSearchLocation oldLocation, + TextComponentSearchLocation newLocation) { + + clearActiveHighlight(oldLocation); + + if (isStale) { + // no way to easily change highlights for the caret position while we are stale, + // since the locations no longer match the document positions + return; + } + + if (newLocation == null) { + return; + } + + newLocation.setActive(true); + doHighlightLocation(newLocation); + scrollToLocation(newLocation); + } + + private void clearActiveHighlight(TextComponentSearchLocation location) { + + if (location == null) { + return; + } + + location.setActive(false); + doHighlightLocation(location); // turn off the active highlight + } + + private void scrollToLocation(TextComponentSearchLocation location) { + if (location == null) { + return; + } + + int caret = editorPane.getCaretPosition(); + if (!location.contains(caret)) { + setCaretPositionInternally(location); + } + scrollToVisible(location); + } + + private void setCaretPositionInternally(TextComponentSearchLocation location) { + + if (isStale) { + // once the document contents have changed, we have know way of knowing if the matches + // are still valid + return; + } + + Document doc = editorPane.getDocument(); + int len = doc.getLength(); + if (len == 0) { + // This can happen if the document is getting loaded asynchronously. We work around + // this elsewhere, making this a very low occurrence event. If it happens, just + // ignore the caret update. + return; + } + + isUpdatingCaretInternally = true; + try { + int pos = location.getStartIndexInclusive(); + editorPane.setCaretPosition(pos); + + } + finally { + isUpdatingCaretInternally = false; + } + } + + private void scrollToVisible(TextComponentSearchLocation location) { + + try { + int start = location.getStartIndexInclusive(); + int end = location.getEndIndexInclusive(); + Rectangle startR = editorPane.modelToView2D(start).getBounds(); + Rectangle endR = editorPane.modelToView2D(end).getBounds(); + endR.width += 20; // a little extra space so the view is not right at the text end + Rectangle union = startR.union(endR); + editorPane.scrollRectToVisible(union); + } + catch (BadLocationException e) { + Msg.debug(this, "Exception scrolling to text", e); + } + } + + private void setActiveHighlightBasedOnCaret() { + if (!isActive()) { + return; + } + + int pos = editorPane.getCaretPosition(); + updateActiveLocationForCaretChange(pos); + } + + /** + * Creates our search highlighter, wrapping any existing highlighter in order to not lose + * client highlights. + * @return the new highlighter + */ + private SearchResultsHighlighterWrapper createHighlighter() { + + Highlighter activeHighlighter = editorPane.getHighlighter(); + if (activeHighlighter == null) { + return new SearchResultsHighlighterWrapper(null); + } + + if (activeHighlighter instanceof SearchResultsHighlighterWrapper wrapper) { + // don't wrap another search highlighter, as we will check for them later + return new SearchResultsHighlighterWrapper(wrapper.delegate); + } + + // some other client non-search highlighter + return new SearchResultsHighlighterWrapper(activeHighlighter); + } + + private SearchResultsHighlighterWrapper getInstalledSearchResultsHighlighter() { + + Highlighter activeHighlighter = editorPane.getHighlighter(); + if (activeHighlighter == null) { + return null; + } + + if (activeHighlighter instanceof SearchResultsHighlighterWrapper wrapper) { + return wrapper; + } + + // some other client non-search highlighter + return null; + } + + /* + We used to use a boolean to track the active state. However, due to how clients activate + and deactivate, the boolean could get out-of-sync with the highlighter. Thus, use the + active highlighter as the method for checking if we are active. + */ + boolean isActive() { + SearchResultsHighlighterWrapper activeSearchHighlighter = + getInstalledSearchResultsHighlighter(); + + return activeSearchHighlighter == highlighter; + } + + Highlight[] getHighlights() { + return highlighter.getHighlights(); + } + + private void maybeInstallHighlighter() { + + SearchResultsHighlighterWrapper activeHighlighter = + getInstalledSearchResultsHighlighter(); + + if (activeHighlighter == highlighter) { + // we are already installed + return; + } + + if (activeHighlighter != null) { + // another search highlighter is installed + activeHighlighter.removeAllHighlights(); + } + + editorPane.setHighlighter(highlighter); + } + + private void applyHighlights() { + + unapplyHighlights(); + + // Any other search highlights will be cleared when we install our highlighter + maybeInstallHighlighter(); + + Collection locations = matchesByPosition.values(); + for (TextComponentSearchLocation location : locations) { + doHighlightLocation(location); + } + + setActiveHighlightBasedOnCaret(); + } + + /** + * Clears highlights, but does not remove known matches. This allows highlights to later be + * restored. + */ + private void unapplyHighlights() { + + // reset and repaint the active highlight + setActiveLocation(null); + + Highlighter activeHighlighter = editorPane.getHighlighter(); + if (activeHighlighter == highlighter) { + // only remove our highlights + highlighter.removeAllHighlights(); + highlighter.uninstall(); + } + } + + private void doHighlightLocation(TextComponentSearchLocation location) { + + Highlighter activeHighlighter = editorPane.getHighlighter(); + if (activeHighlighter != highlighter) { + // Not our highlighter; don't change highlights. Shouldn't happen. + return; + } + + Object tag = location.getHighlightTag(); + if (tag != null) { + // always remove any previous highlight before adding a new one + highlighter.removeHighlight(tag); + } + + if (isStale) { + return; // do not highlight when stale + } + + Color c = location.isActive() ? activeHighlightColor : highlightColor; + HighlightPainter painter = new DefaultHighlightPainter(c); + int start = location.getStartIndexInclusive(); + int end = location.getEndIndexInclusive() + 1; // +1 to make inclusive be exclusive + try { + tag = highlighter.addHighlight(start, end, painter); + location.setHighlightTag(tag); + } + catch (BadLocationException e) { + Msg.debug(this, "Exception adding highlight", e); + } + } + + @Override + public void dispose() { + deactivate(); + caretUpdater.dispose(); + + if (editorPane != null) { + Document document = editorPane.getDocument(); + document.removeDocumentListener(documentListener); + } + + matchesByPosition.clear(); + searchLocations.clear(); + + highlighter.uninstall(); + isStale = true; + } + + @Override + public String toString() { + return "%s: %s (%s)".formatted(getClass().getSimpleName(), searchText, + System.identityHashCode(this)); + } + +//================================================================================================= +// Inner Classes +//================================================================================================= + + private class DocumentChangeListener implements DocumentListener { + + @Override + public void insertUpdate(DocumentEvent e) { + // this allows the previous search results to stay visible until a new find is requested + setStale(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + setStale(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + // ignore attribute changes since they don't affect the text content + } + } + + private class CaretChangeListener implements CaretListener { + + private int lastPos = -1; + + @Override + public void caretUpdate(CaretEvent e) { + int pos = e.getDot(); + if (isUpdatingCaretInternally) { + lastPos = pos; + return; + } + + if (pos == lastPos) { + return; + } + + lastPos = pos; + caretUpdater.update(); + } + } + + /** + * A class that allows us to replace any already installed highlighter. This also allows us to + * add and remove highlighters, depending upon the active search. + *

+ * Note: any non-search highlighters installed after this wrapper is created may be overwritten + * as the usr interacts with the search. + */ + private class SearchResultsHighlighterWrapper extends DefaultHighlighter { + + private Highlighter delegate; + private boolean nonSearchDelegate; + + SearchResultsHighlighterWrapper(Highlighter delegate) { + if (delegate == null) { + delegate = new DefaultHighlighter(); + nonSearchDelegate = false; + } + else { + nonSearchDelegate = true; + } + this.delegate = delegate; + } + + void uninstall() { + Highlighter activeHighlighter = editorPane.getHighlighter(); + if (activeHighlighter != this) { + return; + } + + if (nonSearchDelegate) { + editorPane.setHighlighter(delegate); + } + else { + editorPane.setHighlighter(null); + } + } + + @Override + public void install(JTextComponent c) { + delegate.install(c); + } + + @Override + public void deinstall(JTextComponent c) { + delegate.deinstall(c); + } + + @Override + public void paint(Graphics g) { + delegate.paint(g); + } + + @Override + public void paintLayeredHighlights(Graphics g, int p0, int p1, Shape viewBounds, + JTextComponent editor, View view) { + if (delegate instanceof LayeredHighlighter lh) { + lh.paintLayeredHighlights(g, p0, p1, viewBounds, editor, view); + } + } + + @Override + public Object addHighlight(int p0, int p1, HighlightPainter p) throws BadLocationException { + return delegate.addHighlight(p0, p1, p); + } + + @Override + public void removeHighlight(Object tag) { + delegate.removeHighlight(tag); + } + + @Override + public void removeAllHighlights() { + delegate.removeAllHighlights(); + } + + @Override + public void changeHighlight(Object tag, int p0, int p1) throws BadLocationException { + delegate.changeHighlight(tag, p0, p1); + } + + @Override + public Highlight[] getHighlights() { + return delegate.getHighlights(); + } + + } +} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearcher.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearcher.java new file mode 100644 index 0000000000..3fe9efbe03 --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/search/TextComponentSearcher.java @@ -0,0 +1,335 @@ +/* ### + * 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.search; + +import java.awt.TextComponent; +import java.util.TreeMap; +import java.util.regex.*; + +import javax.swing.JEditorPane; +import javax.swing.text.BadLocationException; +import javax.swing.text.Document; + +import docking.widgets.CursorPosition; +import docking.widgets.SearchLocation; +import ghidra.util.Msg; +import ghidra.util.UserSearchUtils; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.*; +import ghidra.util.worker.Worker; + +/** + * A class to find text matches in the given {@link TextComponent}. This class will search for all + * matches and cache the results for future requests when the user presses Next or Previous. All + * matches will be highlighted in the text component. The match containing the cursor will be a + * different highlight color than the others. When the find dialog is closed, all highlights are + * removed. + *

+ * If {@link #searchAll(String, boolean)} is called, then the search results will not be cached, as + * they are when {@link #search(String, CursorPosition, boolean, boolean)} is used. The expectation + * is that clients will cache the search results themselves. + */ +public class TextComponentSearcher implements FindDialogSearcher { + + static final int MAX_CONTEXT_CHARS = 100; + private int maxContextChars = MAX_CONTEXT_CHARS; + + protected JEditorPane editorPane; + + private Worker worker = Worker.createGuiWorker(); + private TextComponentSearchResults searchResults; + + public TextComponentSearcher(JEditorPane editorPane) { + this.editorPane = editorPane; + } + + public void setEditorPane(JEditorPane editorPane) { + if (this.editorPane != editorPane) { + if (searchResults != null) { + searchResults.dispose(); + searchResults = null; + } + } + this.editorPane = editorPane; + } + + public JEditorPane getEditorPane() { + return editorPane; + } + + void setMaxContextChars(int max) { + this.maxContextChars = max; + } + + @Override + public void dispose() { + + if (searchResults != null) { + searchResults.dispose(); + searchResults = null; + } + } + + public boolean hasSearchResults() { + return searchResults != null && !searchResults.isEmpty(); + } + + public boolean isStale() { + return searchResults != null && searchResults.isStale(); + } + + @Override + public CursorPosition getCursorPosition() { + int pos = editorPane.getCaretPosition(); + return new CursorPosition(pos); + } + + @Override + public CursorPosition getStart() { + return new CursorPosition(0); + } + + @Override + public CursorPosition getEnd() { + int length = editorPane.getDocument().getLength(); + return new CursorPosition(length - 1); + } + + @Override + public TextComponentSearchResults searchAll(String text, boolean useRegex) { + return doSearch(text, useRegex); + } + + @Override + public SearchResults search(String text, CursorPosition cursorPosition, + boolean searchForward, boolean useRegex) { + + updateSearchResults(text, useRegex); + + int pos = cursorPosition.getPosition(); + int searchStart = getSearchStart(pos, searchForward); + if (searchStart == -1) { + return null; // signal no more matches in the current direction + } + + TextComponentSearchLocation location = + searchResults.getNextLocation(searchStart, searchForward); + if (location == null) { + return null; + } + + searchResults.setActiveLocation(location); + return searchResults; + } + + private void updateSearchResults(String text, boolean useRegex) { + if (searchResults != null) { + if (!searchResults.isInvalid(text)) { + + // the current results are still valid; ensure the highlights are still active + searchResults.activate(); + return; + } + + searchResults.dispose(); + searchResults = null; + } + + searchResults = doSearch(text, useRegex); + } + + private TextComponentSearchResults doSearch(String text, boolean useRegex) { + SearchTask searchTask = new SearchTask(text, useRegex); + TaskLauncher.launch(searchTask); + + TextComponentSearchResults newSearchResults = searchTask.doCreateSearchResults(); + newSearchResults.activate(); + return newSearchResults; + } + + private int getSearchStart(int startPosition, boolean isForward) { + + SearchLocation location = searchResults.getActiveLocation(); + if (location == null) { + return startPosition; + } + + int lastMatchStart = location.getStartIndexInclusive(); + if (startPosition != lastMatchStart) { + return startPosition; + } + + // Always prefer the caret position, unless it aligns with the previous match. By + // moving it forward one we will continue our search, as opposed to always matching + // the same hit. + if (isForward) { + int next = startPosition + 1; + int end = editorPane.getText().length(); + if (next == end) { + return -1; // signal no more hits in this direction + } + return next; + } + + // backwards + if (startPosition == 0) { + return -1; // signal no more hits in this direction + } + return startPosition - 1; + } + + protected TextComponentSearchResults createSearchResults( + Worker theWorker, JEditorPane editor, String searchText, + TreeMap matchesByPosition) { + return new TextComponentSearchResults(theWorker, editor, searchText, matchesByPosition); + } + +//================================================================================================= +// Inner Classes +//================================================================================================= + + private class SearchTask extends Task { + + private String searchText; + private TreeMap matchesByPosition = new TreeMap<>(); + private boolean useRegex; + + SearchTask(String searchText, boolean useRegex) { + super("Help Search Task", true, false, true, true); + this.searchText = searchText; + this.useRegex = useRegex; + } + + TextComponentSearchResults doCreateSearchResults() { + return createSearchResults(worker, editorPane, searchText, matchesByPosition); + } + + @Override + public void run(TaskMonitor monitor) throws CancelledException { + + Document document; + String fullText; + try { + document = editorPane.getDocument(); + fullText = document.getText(0, document.getLength()); + } + catch (BadLocationException e) { + Msg.error(this, "Unable to get text for user find operation", e); + return; + } + + TreeMap lineRangeMap = mapLines(fullText); + + Pattern pattern = createSearchPattern(searchText, useRegex); + Matcher matcher = pattern.matcher(fullText); + while (matcher.find()) { + monitor.checkCancelled(); + int start = matcher.start(); + int end = matcher.end(); + Line line = lineRangeMap.floorEntry(start).getValue(); + + String matchText = fullText.substring(start, end); + SearchLocationContext context = createContext(line, start, end); + TextComponentSearchLocation location = + new TextComponentSearchLocation(matchText, start, end - 1, line.lineNumber(), + context); + matchesByPosition.put(start, location); + } + + } + + private TreeMap mapLines(String fullText) { + TreeMap linesRangeMap = new TreeMap<>(); + int lineNumber = 0; + int pos = 0; + String[] lines = fullText.split("\\n"); + for (String line : lines) { + lineNumber++; + linesRangeMap.put(pos, new Line(line, lineNumber, pos)); + pos += line.length() + 1; // +1 for newline + } + return linesRangeMap; + } + + private SearchLocationContext createContext(Line line, int start, int end) { + SearchLocationContextBuilder builder = new SearchLocationContextBuilder(); + String text = line.text(); + int offset = line.offset(); // document offset + int rstart = start - offset; // line-relative start + int rend = end - offset; // line-relative end + int lineStart = 0; + int lineEnd = text.length(); + + int length = text.length(); + int max = maxContextChars; + if (length > max) { + // HTML content can have very long lines, since it doesn't use newline characters to + // break text. We just want to show some context, so we don't need all characters. + // When the text is too long, just grab some surrounding text for the context. + int matchLength = end - start; + int remaining = max - matchLength; + int half = remaining / 2; + int firstHalf = rstart; // from 0 to match start + int available = Math.min(half, firstHalf); + int newStart = rstart - available; + + available = max - (available + matchLength); + int newEnd = Math.min(length, rend + available); + + lineStart = newStart; + lineEnd = newEnd; + } + + if (lineStart != 0) { + builder.append("..."); + } + + builder.append(text.substring(lineStart, rstart)); + builder.appendMatch(text.substring(rstart, rend)); + if (rend < text.length()) { + builder.append(text.substring(rend, lineEnd)); + } + + if (lineEnd < text.length()) { + builder.append("..."); + } + + return builder.build(); + } + + private Pattern createSearchPattern(String searchString, boolean isRegex) { + + int options = Pattern.CASE_INSENSITIVE | Pattern.DOTALL; + if (isRegex) { + try { + return Pattern.compile(searchString, options); + } + catch (PatternSyntaxException e) { + Msg.showError(this, editorPane, "Regular Expression Syntax Error", + e.getMessage()); + return null; + } + } + + return UserSearchUtils.createPattern(searchString, false, options); + } + + record Line(String text, int lineNumber, int offset) { + + } + } + +} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/tabbedpane/DockingTabRenderer.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/tabbedpane/DockingTabRenderer.java index 4e1a92fef0..494ad54871 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/tabbedpane/DockingTabRenderer.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/tabbedpane/DockingTabRenderer.java @@ -41,7 +41,7 @@ public class DockingTabRenderer extends JPanel { private TabContainerForwardingMouseListener forwardingListener; private MouseListener renameListener; - public DockingTabRenderer(final JTabbedPane tabbedPane, String fullTitle, String tabTitle, + public DockingTabRenderer(final JTabbedPane tabbedPane, String fullTitle, String tabText, ActionListener closeListener) { final ForwardingMouseListener eventForwardingListener = @@ -51,8 +51,8 @@ public class DockingTabRenderer extends JPanel { iconLabel = new GDLabel(); closeButton = new EmptyBorderButton(); - setTitle(tabTitle, fullTitle); - closeButton.setToolTipText("Close " + tabTitle); + setTitle(tabText, fullTitle); + closeButton.setToolTipText("Close " + tabText); closeButton.setFocusable(false); closeButton.addActionListener(closeListener); closeButton.setIcon(CLOSE_ICON); @@ -134,13 +134,13 @@ public class DockingTabRenderer extends JPanel { iconLabel.setIcon(icon); } - public void setTitle(String tabTitle, String fullTitle) { - titleLabel.setText(getShortenedTitle(tabTitle)); - String trimmedTabText = tabTitle.trim(); + public void setTitle(String tabText, String fullTitle) { + titleLabel.setText(getShortenedTitle(tabText)); + String trimmedTabText = tabText.trim(); String trimmedTitleText = fullTitle.trim(); if (trimmedTabText.equals(trimmedTitleText)) { // don't include the same text on twice - titleLabel.setToolTipText(tabTitle); + titleLabel.setToolTipText(tabText); } else if (trimmedTitleText.contains(trimmedTabText)) { // don't include both when the tab text is a subset of the title @@ -148,7 +148,7 @@ public class DockingTabRenderer extends JPanel { } else { // both are different, include both - titleLabel.setToolTipText("" + tabTitle + " - [" + fullTitle + "]"); + titleLabel.setToolTipText("" + tabText + " - [" + fullTitle + "]"); } } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/util/table/actions/DeleteTableRowAction.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/table/actions/DeleteTableRowAction.java similarity index 93% rename from Ghidra/Features/Base/src/main/java/ghidra/util/table/actions/DeleteTableRowAction.java rename to Ghidra/Framework/Docking/src/main/java/docking/widgets/table/actions/DeleteTableRowAction.java index fc378ca91f..befb99b787 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/util/table/actions/DeleteTableRowAction.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/table/actions/DeleteTableRowAction.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package ghidra.util.table.actions; +package docking.widgets.table.actions; import java.awt.event.KeyEvent; import java.util.ArrayList; @@ -24,14 +24,13 @@ import javax.swing.KeyStroke; import javax.swing.table.TableModel; import docking.ActionContext; +import docking.Tool; import docking.action.*; import docking.actions.SharedDockingActionPlaceholder; import docking.widgets.table.GTable; import docking.widgets.table.RowObjectTableModel; import docking.widgets.table.threaded.ThreadedTableModel; import generic.theme.GIcon; -import ghidra.app.util.HelpTopics; -import ghidra.framework.plugintool.PluginTool; import ghidra.util.*; import ghidra.util.exception.AssertException; import ghidra.util.timer.GTimer; @@ -46,7 +45,7 @@ import ghidra.util.timer.GTimer; * not altering the database. *

* Tip: if you are a plugin that uses transient providers, then use - * {@link #registerDummy(PluginTool, String)} at creation time to install a dummy representative of + * {@link #registerDummy(Tool, String)} at creation time to install a dummy representative of * this action in the Tool's options so that user's can update keybindings, regardless of whether * they have ever shown one of your transient providers. */ @@ -54,7 +53,7 @@ public class DeleteTableRowAction extends DockingAction { private static final KeyStroke DEFAULT_KEYSTROKE = KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0); - private static final Icon ICON = new GIcon("icon.plugin.table.delete.row"); + private static final Icon ICON = new GIcon("icon.widget.table.delete.row"); private static final String NAME = "Remove Items"; private GTable table; @@ -67,7 +66,7 @@ public class DeleteTableRowAction extends DockingAction { * @param tool the tool whose options will updated with a dummy keybinding * @param owner the owner of the action that may be installed */ - public static void registerDummy(PluginTool tool, String owner) { + public static void registerDummy(Tool tool, String owner) { tool.getToolActions().registerSharedActionPlaceholder(new DeleteActionPlaceholder(owner)); } @@ -85,7 +84,7 @@ public class DeleteTableRowAction extends DockingAction { super(name, owner, KeyBindingType.SHARED); setDescription("Remove the selected rows from the table"); - setHelpLocation(new HelpLocation(HelpTopics.SEARCH, "Remove_Items")); + setHelpLocation(new HelpLocation("Search", "Remove_Items")); setToolBarData(new ToolBarData(ICON, null)); setPopupMenuData(new MenuData(new String[] { "Remove Items" }, ICON, menuGroup)); @@ -166,7 +165,7 @@ public class DeleteTableRowAction extends DockingAction { return false; } - private void selectRow(TableModel model, final int row) { + protected void selectRow(TableModel model, final int row) { Swing.runLater(() -> { if (checkForBusy(model)) { diff --git a/Ghidra/Features/Base/src/main/resources/images/table_delete.png b/Ghidra/Framework/Docking/src/main/resources/images/table_delete.png similarity index 100% rename from Ghidra/Features/Base/src/main/resources/images/table_delete.png rename to Ghidra/Framework/Docking/src/main/resources/images/table_delete.png diff --git a/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/FindDialogTest.java b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/FindDialogTest.java index 1a3245ea06..2247a6f5cd 100644 --- a/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/FindDialogTest.java +++ b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/FindDialogTest.java @@ -21,6 +21,8 @@ import java.util.List; import org.junit.Test; +import docking.widgets.search.FindDialogSearcher; +import docking.widgets.search.SearchResults; import ghidra.util.Swing; public class FindDialogTest { @@ -43,11 +45,6 @@ public class FindDialogTest { return new CursorPosition(0); } - @Override - public void setCursorPosition(CursorPosition position) { - // stub - } - @Override public CursorPosition getStart() { return new CursorPosition(0); @@ -59,20 +56,19 @@ public class FindDialogTest { } @Override - public void highlightSearchResults(SearchLocation location) { - // stub - } - - @Override - public void clearHighlights() { - // stub - } - - @Override - public SearchLocation search(String text, CursorPosition cursorPosition, + public SearchResults search(String text, CursorPosition cursorPosition, boolean searchForward, boolean useRegex) { return null; } + @Override + public SearchResults searchAll(String text, boolean useRegex) { + return null; + } + + @Override + public void dispose() { + // stub + } } } diff --git a/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/search/TextComponentSearcherTest.java b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/search/TextComponentSearcherTest.java new file mode 100644 index 0000000000..80648ad5a6 --- /dev/null +++ b/Ghidra/Framework/Docking/src/test.slow/java/docking/widgets/search/TextComponentSearcherTest.java @@ -0,0 +1,400 @@ +/* ### + * 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.search; + +import static org.junit.Assert.*; + +import java.util.*; +import java.util.Map.Entry; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.swing.*; +import javax.swing.text.Highlighter.Highlight; +import javax.swing.text.StyledDocument; + +import org.apache.commons.lang3.StringUtils; +import org.junit.Before; +import org.junit.Test; + +import docking.test.AbstractDockingTest; +import docking.widgets.CursorPosition; +import docking.widgets.SearchLocation; +import ghidra.util.worker.Worker; + +public class TextComponentSearcherTest extends AbstractDockingTest { + + private JTextPane textPane = new JTextPane(); + private TextComponentSearcher searcher = new TextComponentSearcher(textPane); + + private int lineCount = 0; + private List expectedMatches = new ArrayList<>(); + + @Before + public void setUp() { + + JScrollPane scrollPane = new JScrollPane(textPane); + + JFrame frame = new JFrame("Find Dialog Test"); + frame.setSize(400, 400); + frame.getContentPane().add(scrollPane); + frame.setVisible(true); + waitForSwing(); + } + + @Test + public void testFindNextPrevious_ChangeDocument() throws Exception { + + // After changing the document, the results are stale and highlights should not work. But, + // the results should remain. + + createDocumenText(); + + String searchText = "text"; + TextComponentSearchResults results = searchNext(searchText); + assertFalse(isEmpty(results)); + assertValid(results, searchText); + assertTrue(hasHighlights(results)); + + add("More text in the document after the search"); + waitFor(results); + assertInvalid(results, searchText); + assertFalse(isEmpty(results)); + assertFalse(hasHighlights(results)); + + // call search again and get new, valid results + TextComponentSearchResults newResults = searchNext(searchText); + assertNotEquals(results, newResults); + assertFalse(isEmpty(newResults)); + assertValid(newResults, searchText); + assertTrue(hasHighlights(newResults)); + } + + @Test + public void testSearchAll_ChangeDocument() throws Exception { + + // After changing the document, the results are stale and highlights should not work. But, + // the results should remain. + createDocumenText(); + + String searchText = "text"; + TextComponentSearchResults results = searchAll(searchText); + assertFalse(isEmpty(results)); + assertValid(results, searchText); + assertTrue(hasHighlights(results)); + + add("More text in the document after the search"); + waitFor(results); + assertInvalid(results, searchText); + assertFalse(isEmpty(results)); + assertFalse(hasHighlights(results)); + + // call search again and get new, valid results + TextComponentSearchResults newResults = searchAll(searchText); + assertNotEquals(results, newResults); + assertFalse(isEmpty(newResults)); + assertValid(newResults, searchText); + assertTrue(hasHighlights(newResults)); + } + + @Test + public void testSearchAll_ContextTruncation_EvenLimit() throws Exception { + // + // Test that the context generated for the search results gets correctly truncated. We test + // various boundary conditions + // + int max = 20; + doTest(max); + } + + @Test + public void testSearchAll_ContextTruncation_OddLimit() throws Exception { + int max = 21; + doTest(max); + } + + @Test + public void testSearchAll_ManySearches_Activation() throws Exception { + // + // Test that we can activate and deactivate many search results rapidly and get the correct + // behavior. There is a worker queue managing activation requests. We hope to ensure the + // jobs are processed correctly. + // + createDocumenText(); + + // text + Map allResults = new HashMap<>(); + SearchResults results = searchAll("text"); + allResults.put("text", results); + + // some + results = searchAll("some"); + allResults.put("some", results); + + // leading + results = searchAll("leading"); + allResults.put("leading", results); + + // trailing + results = searchAll("trailing"); + allResults.put("trailing", results); + + Set> entries = allResults.entrySet(); + for (Entry entry : entries) { + String searchText = entry.getKey(); + TextComponentSearchResults searchResults = + (TextComponentSearchResults) entry.getValue(); + searchResults.activate(); + waitFor(searchResults); + assertTrue("Search results did not activate '%s'".formatted(searchText), + searchResults.isActive()); + } + + // Each call could possible cancel any activate request that has not finished. The last + // request should be active. + allResults.get("leading").activate(); + allResults.get("trailing").activate(); + allResults.get("leading").activate(); + allResults.get("text").activate(); + allResults.get("some").activate(); + + String searchText = "some"; + TextComponentSearchResults lastResults = + (TextComponentSearchResults) allResults.get(searchText); + waitFor(lastResults); + assertTrue("Search results did not activate '%s'".formatted(searchText), + lastResults.isActive()); + } + + @Test + public void testSearchAll_ManySearches_Disposal() throws Exception { + // + // Test that we can activate and deactivate many search results rapidly and get the correct + // behavior. There is a worker queue managing activation requests. We hope to ensure the + // jobs are processed correctly. + // + createDocumenText(); + + TextComponentSearchResults results = searchAll("text"); + assertTrue(results.isActive()); + + results.deactivate(); + assertInactive(results); + + results.activate(); + assertActive(results); + + results.dispose(); + assertInactive(results); + + // make sure active() does not work once disposed + results.activate(); + assertDisposed(results); + } + + private TextComponentSearchResults searchNext(String text) { + CursorPosition cursor = new CursorPosition(0); + SearchResults results = searcher.search(text, cursor, true, false); + waitFor(results); + return (TextComponentSearchResults) results; + } + + private TextComponentSearchResults searchAll(String text) { + TextComponentSearchResults results = searcher.searchAll(text, false); + waitFor(results); + return results; + } + + private void waitFor(SearchResults results) { + Worker worker = results.getWorker(); + waitFor(() -> !worker.isBusy()); + } + + private void doTest(int max) throws Exception { + runSwing(() -> searcher.setMaxContextChars(max)); + + String searchText = "gold"; + createDocumenText(searchText, max); + + SearchResults results = searchAll(searchText); + assertMatches(results, max); + } + + private void createDocumenText() throws Exception { + createDocumenText("stuff", 20); + } + + private void createDocumenText(String searchText, int max) throws Exception { + add("No match on this line", false); + + add("%s", searchText); + + // non-truncated + add("We found %s here", searchText); + + // truncated middle match + add("This text will be (%s) truncated, since it is more than max", searchText); + + // truncated beginning match + add("%s, this text will be truncated, since it is more than max", searchText); + + // truncated end match + add("This text will be truncated, since it is more than max, %s", searchText); + + // truncated at beginning boundary + int half = (max - searchText.length()) / 2; // 'half' of the available space; max includes search text + add(padLeft(half, "%s with some trailing text that is quite long", searchText)); + add(padLeft(half - 1, "%s with some trailing text that is quite long", searchText)); + add(padLeft(half + 1, "%s with some trailing text that is quite long", searchText)); + + // truncated at end boundary + add(padRight(half, "this is some leading text that is quite long, %s", searchText)); + add(padRight(half - 1, "this is some leading text that is quite long, %s", searchText)); + add(padRight(half + 1, "this is some leading text that is quite long, %s", searchText)); + + // truncated at beginning at ellipses + add(padLeft(half, "%s with some trailing text that is quite long", searchText)); + } + + private void assertMatches(SearchResults results, int max) { + + List locations = results.getLocations(); + Map locationsByLine = locations.stream() + .collect(Collectors.toMap(loc -> loc.getLineNumber(), Function.identity())); + + // Debug +// Set> entries1 = locationsByLine.entrySet(); +// for (Entry entry : entries1) { +// SearchLocation loc = entry.getValue(); +// Msg.debug(this, loc.getContext()); +// } + + for (Line line : expectedMatches) { + int n = line.lineNumber(); + SearchLocation result = locationsByLine.remove(n); + assertNotNull("No match at line: " + n, result); + + SearchLocationContext context = result.getContext(); + String text = context.getPlainText(); + int length = text.length(); + int maxWithEllipses = max + 6; // ... text ... + assertTrue("Length is to long. Expected max %s, but found %s" + .formatted(maxWithEllipses, length), + length <= maxWithEllipses); + } + + if (!locationsByLine.isEmpty()) { + + StringBuilder sb = new StringBuilder(); + sb.append("Found more search results than expected:").append('\n'); + + Set> entries = locationsByLine.entrySet(); + for (Entry entry : entries) { + SearchLocation loc = entry.getValue(); + sb.append(loc.toString()).append('\n'); + } + + fail(sb.toString()); + } + } + + private void assertValid(TextComponentSearchResults results, String searchText) { + boolean invalid = runSwing(() -> results.isInvalid(searchText)); + assertFalse(invalid); + } + + private void assertInvalid(TextComponentSearchResults results, String searchText) { + boolean invalid = runSwing(() -> results.isInvalid(searchText)); + assertTrue(invalid); + } + + private boolean isEmpty(TextComponentSearchResults results) { + return runSwing(() -> results.isEmpty()); + } + + private boolean hasHighlights(TextComponentSearchResults results) { + Highlight[] highlights = runSwing(() -> results.getHighlights()); + return highlights.length > 0; + } + + private void assertActive(TextComponentSearchResults results) { + waitFor(results); + assertTrue(results.isActive()); + Highlight[] highlights = runSwing(() -> results.getHighlights()); + List locations = results.getLocations(); + assertEquals(locations.size(), highlights.length); + } + + private void assertInactive(TextComponentSearchResults results) { + waitFor(results); + assertFalse(results.isActive()); + Highlight[] highlights = runSwing(() -> results.getHighlights()); + assertEquals(0, highlights.length); + } + + private void assertDisposed(TextComponentSearchResults results) { + waitFor(results); + assertFalse(results.isActive()); + Highlight[] highlights = runSwing(() -> results.getHighlights()); + assertEquals(0, highlights.length); + List locations = results.getLocations(); + assertEquals(0, locations.size()); + } + + private String padLeft(int n, String s, String searchText) { + s = s.formatted(searchText); + String pad = StringUtils.repeat('@', n); + return pad + s; + } + + private String padRight(int n, String s, String searchText) { + s = s.formatted(searchText); + String pad = StringUtils.repeat('@', n); + return s + pad; + } + + private void add(String s) throws Exception { + add(s, true); + } + + private void add(String raw, String searchText) throws Exception { + String s = raw; + boolean hasMatch = searchText != null; + if (hasMatch) { + s = raw.formatted(searchText); + } + add(s, true); + } + + private void add(String s, boolean hasMatch) throws Exception { + + runSwingWithException(() -> { + StyledDocument sd = textPane.getStyledDocument(); + sd.insertString(sd.getLength(), s + '\n', null); + }); + + lineCount++; + + if (!hasMatch) { + return; + } + + Line line = new Line(s, lineCount); + expectedMatches.add(line); + } + + private record Line(String text, int lineNumber) {} +} diff --git a/Ghidra/Framework/Generic/src/main/java/generic/concurrent/ConcurrentQ.java b/Ghidra/Framework/Generic/src/main/java/generic/concurrent/ConcurrentQ.java index 6b91d4ea4c..5b375a3754 100644 --- a/Ghidra/Framework/Generic/src/main/java/generic/concurrent/ConcurrentQ.java +++ b/Ghidra/Framework/Generic/src/main/java/generic/concurrent/ConcurrentQ.java @@ -18,6 +18,8 @@ package generic.concurrent; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Predicate; +import java.util.stream.Collectors; import ghidra.util.task.CancelledListener; import ghidra.util.task.TaskMonitor; @@ -487,29 +489,65 @@ public class ConcurrentQ { * @return a list of all items that have not yet been queued to the threadPool. */ public List cancelAllTasks(boolean interruptRunningTasks) { - List> tasksToBeCancelled = new ArrayList<>(); + return cancelAllTasks(i -> true, interruptRunningTasks); + } + + /** + * Cancels the processing of currently scheduled items in this queue that match the given + * predicate. Any items that haven't yet been scheduled on the threadPool are returned + * immediately from this call. Items that are currently being processed will be cancelled and + * those results will be available on the next waitForResults() call and also if there is a + * QItemListener, it will be called with the QResult. There is no guarantee that scheduled + * tasks will terminate any time soon. If they check the isCancelled() state of their QMonitor, + * it will be true. Setting the interruptRunningTasks to true, will result in a thread + * interrupt to any currently running task which might be useful if the task perform waiting + * operations like I/O. + * + * @param p the predicate that signals which jobs to cancel + * @param interruptRunningTasks if true, an attempt will be made to interrupt any currently + * processing thread. + * @return a list of all items that have not yet been queued to the threadPool. + */ + public List cancelAllTasks(Predicate p, boolean interruptRunningTasks) { + List> tasksToCancel; List nonStartedItems; lock.lock(); try { - nonStartedItems = removeUnscheduledJobs(); - tasksToBeCancelled.addAll(taskSet); + nonStartedItems = removeUnscheduledJobs(p); + tasksToCancel = taskSet.stream() + .filter(t -> p.test(t.getItem())) + .collect(Collectors.toList()); } finally { lock.unlock(); } - for (FutureTaskMonitor task : tasksToBeCancelled) { + + for (FutureTaskMonitor task : tasksToCancel) { task.cancel(interruptRunningTasks); } return nonStartedItems; } + /** + * Removes all unscheduled jobs + * @return the removed jobs + */ public List removeUnscheduledJobs() { + return removeUnscheduledJobs(i -> true); + } + + /** + * Removes all unscheduled jobs matching the given predicate + * @param p the predicate + * @return the removed jobs + */ + public List removeUnscheduledJobs(Predicate p) { List nonStartedItems = new ArrayList<>(); lock.lock(); try { - tracker.neverStartedItemsRemoved(queue.size()); - nonStartedItems.addAll(queue); - queue.clear(); + nonStartedItems = queue.stream().filter(p).collect(Collectors.toList()); + tracker.neverStartedItemsRemoved(nonStartedItems.size()); + queue.removeAll(nonStartedItems); } finally { lock.unlock(); diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/AbstractWorker.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/AbstractWorker.java index b595e197a8..9f6daa8819 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/AbstractWorker.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/AbstractWorker.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. @@ -19,6 +19,7 @@ import java.util.List; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; import generic.concurrent.*; import ghidra.util.Msg; @@ -187,9 +188,8 @@ public abstract class AbstractWorker { } /** - * Schedules the job for execution. Jobs will be processed in priority order. The - * highest priority jobs are those with the lowest value return by the job's getPriority() - * method. (i.e. the job with priority 0 will be processed before the job with priority 1) + * Schedules the job for execution. Jobs will be processed according to the queue supplied at + * construction time (e.g., in priority order or 1 at a time). * @param job the job to be executed. */ public void schedule(T job) { @@ -206,7 +206,22 @@ public abstract class AbstractWorker { * Clears any pending jobs and cancels any currently executing job. */ public void clearAllJobs() { - clearAllJobs(false); + clearAllJobs(t -> true); + } + + /** + * Clears any pending jobs and currently executing jobs that match the given predicate. + * @param p the predicate + */ + public void clearAllJobs(Predicate p) { + doClearAllJobs(p, false); + } + + private void doClearAllJobs(Predicate p, boolean interruptRunninJob) { + List pendingJobs = concurrentQ.cancelAllTasks(p, interruptRunninJob); + for (T job : pendingJobs) { + job.cancel(); + } } /** @@ -222,14 +237,7 @@ public abstract class AbstractWorker { * */ public void clearAllJobsWithInterrupt_IKnowTheRisks() { - clearAllJobs(true); - } - - private void clearAllJobs(boolean interruptRuningJob) { - List pendingJobs = concurrentQ.cancelAllTasks(interruptRuningJob); - for (T job : pendingJobs) { - job.cancel(); - } + doClearAllJobs(t -> true, true); } /** diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/PriorityWorker.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/PriorityWorker.java index b0b55372a8..f7f7918517 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/PriorityWorker.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/PriorityWorker.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. @@ -15,14 +15,16 @@ */ package ghidra.util.worker; -import ghidra.util.task.TaskMonitor; - import java.util.Comparator; import java.util.concurrent.PriorityBlockingQueue; +import ghidra.util.task.TaskMonitor; + /** * Executes a single job at a time in priority order. - * + *

+ * The highest priority jobs are those with the lowest value return by the job's getPriority() + * method. (i.e. the job with priority 0 will be processed before the job with priority 1) * @see Worker */ public class PriorityWorker extends AbstractWorker { diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/Worker.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/Worker.java index a7546c2fc4..7e5786c4d9 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/Worker.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/worker/Worker.java @@ -84,5 +84,4 @@ public class Worker extends AbstractWorker { super(new LinkedBlockingQueue(), isPersistentThread, name, useSharedThreadPool, monitor); } - } diff --git a/Ghidra/Framework/Gui/data/gui.palette.theme.properties b/Ghidra/Framework/Gui/data/gui.palette.theme.properties index 82cf7ef7d1..3690974e47 100644 --- a/Ghidra/Framework/Gui/data/gui.palette.theme.properties +++ b/Ghidra/Framework/Gui/data/gui.palette.theme.properties @@ -75,6 +75,7 @@ color.palette.aliceblue = rgb(45,47,65) // dark blue gray color.palette.black = darkgray color.palette.blue = lightskyblue color.palette.blueviolet = violet +color.palette.cornflowerblue = rgb(61,91,145) // less bright cornflowerblue color.palette.crimson = lightcoral color.palette.cyan = cadetblue // not sure; can change color.palette.darkgray = dimgray