diff --git a/Ghidra/Features/Decompiler/data/decompiler.theme.properties b/Ghidra/Features/Decompiler/data/decompiler.theme.properties index b94edc13b5..f1a2f31b59 100644 --- a/Ghidra/Features/Decompiler/data/decompiler.theme.properties +++ b/Ghidra/Features/Decompiler/data/decompiler.theme.properties @@ -41,6 +41,7 @@ icon.decompiler.action.provider.clone = icon.provider.clone icon.decompiler.action.provider.unreachable = eliminateUnreachable.png icon.decompiler.action.provider.readonly = readOnly.png icon.decompiler.action.export = page_edit.png +icon.decompiler.action.display.lock = lock.gif font.decompiler = font.monospaced font.decompiler.pcode.dfg = font.graphdisplay.default diff --git a/Ghidra/Features/Decompiler/src/main/doc/decompileplugin.xml b/Ghidra/Features/Decompiler/src/main/doc/decompileplugin.xml index a3a301b715..25289be0fe 100644 --- a/Ghidra/Features/Decompiler/src/main/doc/decompileplugin.xml +++ b/Ghidra/Features/Decompiler/src/main/doc/decompileplugin.xml @@ -4009,7 +4009,7 @@ tokens (see ). - + Cross-Highlighting The main window maintains a map between the individual variable and operator tokens displayed in @@ -4045,6 +4045,25 @@ +
+ Disabling Auto Refresh + + For very large functions, the decompile window can take a significant amount of time to re-decompile + and update the display as changes are made to the program. This makes some actions frustrating, + such as renaming or retyping variables. + + + As a work around, you may use the lock toggle action + + + +  . + When selected, this action will prevent the decompiler from auto refreshing for each change. + This may make the display appear to be broken since any changes will not appear to + take effect until the display is manually refreshed. + +
+
Snapshot Windows @@ -4085,6 +4104,18 @@ Double-clicking on specific tokens within the Snapshot window may also cause it to navigate to a new location (see ). + + + Normally, snapshot windows are completely disconnected from the location and selection + events that synchronize the main components in the tool. To allow the snapshot window + to export its location and selection, select the + + + +   + toolbar action. + +
diff --git a/Ghidra/Features/Decompiler/src/main/help/help/topics/DecompilePlugin/DecompilerWindow.html b/Ghidra/Features/Decompiler/src/main/help/help/topics/DecompilePlugin/DecompilerWindow.html index 29a80d5a11..e34880eaac 100644 --- a/Ghidra/Features/Decompiler/src/main/help/help/topics/DecompilePlugin/DecompilerWindow.html +++ b/Ghidra/Features/Decompiler/src/main/help/help/topics/DecompilePlugin/DecompilerWindow.html @@ -101,7 +101,7 @@ tokens (see Mouse Actions).

-
+

Cross-Highlighting

@@ -145,6 +145,23 @@
+
+

+Disabling Auto Refresh

+ +

+ For very large functions, the decompile window can take a significant amount of time to re-decompile + and update the display as changes are made to the program. This makes using some actions + frustrating, such as renaming or retyping variables. +

+

+ As a work around, you may use the lock toggle action + . When selected, this action will prevent + the decompiler from auto refreshing for each change. This may make the display appear to be + broken since any changes will not appear to take effect until the display is manually refreshed. +

+
+

Snapshot Windows

@@ -187,6 +204,14 @@ Double-clicking on specific tokens within the Snapshot window may also cause it to navigate to a new location (see Double-Click).

+ +

+ Normally, snapshot windows are completely disconnected from the location and selection + events that synchronize the main components in the tool. To allow the snapshot window + to export its location and selection, select the + toolbar action. +

+
diff --git a/Ghidra/Features/Decompiler/src/main/help/help/topics/DecompilePlugin/images/DecompWindow.png b/Ghidra/Features/Decompiler/src/main/help/help/topics/DecompilePlugin/images/DecompWindow.png index 273b4ebe9c..5f6198d621 100644 Binary files a/Ghidra/Features/Decompiler/src/main/help/help/topics/DecompilePlugin/images/DecompWindow.png and b/Ghidra/Features/Decompiler/src/main/help/help/topics/DecompilePlugin/images/DecompWindow.png differ 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 6aee312c03..2e626c8115 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 @@ -1388,6 +1388,23 @@ public class DecompilerPanel extends JPanel implements FieldMouseListener, Field fieldPanel.removeFocusListener(l); } + /** + * {@return the bounds of the content area of this decompiler panel. This includes the main + * decompiler content panel and the line numbers panel} + */ + public Rectangle getViewContentBounds() { + // The bounds we want includes both the extent size of the main decompiler view + the + // area that displays the line numbers which is not inside the IndexedScrollPane. The width + // of the line numbers panel can be found by looking at the x position of the scroller as + // it is offset by the line number panel's width. We are also assuming there are no borders + // internal to the DecompilerPanel. If that changes, we would also need to factor in the + // insets. + Rectangle bounds = scroller.getBounds(); + Dimension scrollerSize = scroller.getViewExtentSize(); + int lineNumberWidth = bounds.x; + return new Rectangle(0, 0, scrollerSize.width + lineNumberWidth, scrollerSize.height); + } + private void buildPanels() { removeAll(); add(buildLeftComponent(), BorderLayout.WEST); diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/DecompilePlugin.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/DecompilePlugin.java index a97ddb8dd1..0188a69b5e 100644 --- a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/DecompilePlugin.java +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/DecompilePlugin.java @@ -207,13 +207,13 @@ public class DecompilePlugin extends Plugin { } void locationChanged(DecompilerProvider provider, ProgramLocation location) { - if (provider == connectedProvider) { + if (provider.shouldSendEvents()) { firePluginEvent(new ProgramLocationPluginEvent(name, location, location.getProgram())); } } void selectionChanged(DecompilerProvider provider, ProgramSelection selection) { - if (provider == connectedProvider) { + if (provider.shouldSendEvents()) { firePluginEvent(new ProgramSelectionPluginEvent(name, selection, currentProgram)); } } diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/DecompilerProvider.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/DecompilerProvider.java index 7a229ffe26..dd74b391f0 100644 --- a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/DecompilerProvider.java +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/DecompilerProvider.java @@ -15,17 +15,20 @@ */ package ghidra.app.plugin.core.decompile; +import java.awt.Graphics; import java.awt.event.KeyEvent; import java.awt.event.MouseEvent; import java.math.BigInteger; import java.util.*; import java.util.concurrent.ConcurrentLinkedQueue; -import javax.swing.Icon; -import javax.swing.JComponent; +import javax.swing.*; import docking.*; import docking.action.*; +import docking.action.builder.ActionBuilder; +import docking.action.builder.ToggleActionBuilder; +import docking.actions.KeyBindingUtils; import docking.widgets.fieldpanel.support.FieldLocation; import docking.widgets.fieldpanel.support.ViewerPosition; import generic.theme.GIcon; @@ -76,6 +79,7 @@ public class DecompilerProvider extends NavigatableComponentProviderAdapter private static final Icon TOGGLE_READ_ONLY_DISABLED_ICON = new MultiIconBuilder(TOGGLE_READ_ONLY_ICON).addCenteredIcon(SLASH_ICON).build(); + private static final Icon LOCK_DISPLAY_ICON = new GIcon("icon.decompiler.action.display.lock"); private DockingAction pcodeGraphAction; private DockingAction astGraphAction; @@ -100,11 +104,16 @@ public class DecompilerProvider extends NavigatableComponentProviderAdapter private SwingUpdateManager redecompileUpdater; private DecompilerProgramListener programListener; - + private boolean lockDisplay; // Follow-up work can be items that need to happen after a pending decompile is finished, such // as updating highlights after a variable rename private SwingUpdateManager followUpWorkUpdater; private Queue followUpWork = new ConcurrentLinkedQueue<>(); + private OverlayMessagePainter overlayPainter = new OverlayMessagePainter(); + private DockingAction refreshAction; + + // only used by disconnected providers + private boolean allowOutgoingEvents = false; private ServiceListener serviceListener = new ServiceListener() { @@ -140,7 +149,14 @@ public class DecompilerProvider extends NavigatableComponentProviderAdapter // TODO move the hl controller into the panel highlightController = new LocationClangHighlightController(); decompilerPanel.setHighlightController(highlightController); - decorationPanel = new DecoratorPanel(decompilerPanel, isConnected); + decorationPanel = new DecoratorPanel(decompilerPanel, isConnected) { + @Override + public void paint(Graphics g) { + super.paint(g); + overlayPainter.paintOverlay(g, decompilerPanel.getViewContentBounds()); + } + + }; if (!isConnected) { setTransient(); @@ -333,7 +349,28 @@ public class DecompilerProvider extends NavigatableComponentProviderAdapter controller.setOptions(decompilerOptions); if (currentLocation != null) { - controller.refreshDisplay(program, currentLocation, null); + if (lockDisplay) { + overlayPainter.setMessage(getOverlayRefreshMessage()); + } + else { + controller.refreshDisplay(program, currentLocation, null); + overlayPainter.setMessage(""); + } + } + } + + private String getOverlayRefreshMessage() { + KeyStroke keyStroke = refreshAction.getKeyBinding(); + if (keyStroke != null) { + String name = KeyBindingUtils.parseKeyStroke(keyStroke); + return name + " to refresh"; + } + return "Refresh needed"; + } + + private void updateOverlayMessage() { + if (overlayPainter.isActive()) { + overlayPainter.setMessage(getOverlayRefreshMessage()); } } @@ -371,6 +408,8 @@ public class DecompilerProvider extends NavigatableComponentProviderAdapter options.getName().equals(GhidraOptions.CATEGORY_BROWSER_FIELDS)) { doRefresh(true); } + updateOverlayMessage(); + } //================================================================================================== @@ -489,6 +528,7 @@ public class DecompilerProvider extends NavigatableComponentProviderAdapter */ void refresh() { controller.refreshDisplay(program, currentLocation, null); + overlayPainter.setMessage(""); } /** @@ -790,28 +830,35 @@ public class DecompilerProvider extends NavigatableComponentProviderAdapter private void createActions(boolean isConnected) { String owner = plugin.getName(); + new ToggleActionBuilder("Lock Display", owner) + .toolBarIcon(LOCK_DISPLAY_ICON) + .description("Lock display for auto-updates, only update on manual refresh") + .helpLocation(new HelpLocation(HelpTopics.DECOMPILER, "LockDisplay")) + .selected(false) + .onAction(c -> toggleDisplayLock()) + .buildAndInstallLocal(this); + + if (!isConnected) { + new ToggleActionBuilder("Decompiler Outgoing Events", owner) + .toolBarIcon(Icons.NAVIGATE_ON_OUTGOING_EVENT_ICON) + .description("Send location and selection events") + .helpLocation(new HelpLocation(HelpTopics.DECOMPILER, "EventsOut")) + .selected(false) + .onAction(c -> toggleOutgoingEvents()) + .buildAndInstallLocal(this); + } + SelectAllAction selectAllAction = new SelectAllAction(owner, controller.getDecompilerPanel()); - DockingAction refreshAction = new DockingAction("Refresh", owner) { - @Override - public void actionPerformed(ActionContext context) { - refresh(); - } - - @Override - public boolean isEnabledForContext(ActionContext context) { - DecompileData decompileData = controller.getDecompileData(); - if (decompileData == null) { - return false; - } - return decompileData.hasDecompileResults(); - } - }; - refreshAction.setToolBarData(new ToolBarData(REFRESH_ICON, "A" /* first on toolbar */)); - refreshAction.setDescription("Push at any time to trigger a re-decompile"); - refreshAction - .setHelpLocation(new HelpLocation(HelpTopics.DECOMPILER, "ToolBarRedecompile")); // just use the default + refreshAction = new ActionBuilder("Refresh", owner) + .popupMenuPath("Refresh") + .popupMenuIcon(REFRESH_ICON) + .keyBinding("F5") + .helpLocation(new HelpLocation(HelpTopics.DECOMPILER, "ToolBarRedecompile")) + .description("Re-decompile and update the display") + .onAction(c -> refresh()) + .buildAndInstallLocal(this); displayUnreachableCodeToggle = new ToggleDockingAction("Toggle Unreachable Code", owner) { @Override @@ -1120,6 +1167,8 @@ public class DecompilerProvider extends NavigatableComponentProviderAdapter findReferencesToAddressAction.getPopupMenuData().setParentMenuGroup(referencesParentGroup); addLocalAction(findReferencesToAddressAction); + setGroupInfo(refreshAction, "comment6", subGroupPosition++); + // // Options // @@ -1200,6 +1249,24 @@ public class DecompilerProvider extends NavigatableComponentProviderAdapter graphServiceAdded(); } + private void toggleOutgoingEvents() { + allowOutgoingEvents = !allowOutgoingEvents; + } + + boolean shouldSendEvents() { + if (isConnected()) { + return true; + } + return allowOutgoingEvents; + } + + private void toggleDisplayLock() { + lockDisplay = !lockDisplay; + if (!lockDisplay) { + refresh(); + } + } + /** * Sets the group and subgroup information for the given action. */ diff --git a/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/OverlayMessagePainter.java b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/OverlayMessagePainter.java new file mode 100644 index 0000000000..ca393e7654 --- /dev/null +++ b/Ghidra/Features/Decompiler/src/main/java/ghidra/app/plugin/core/decompile/OverlayMessagePainter.java @@ -0,0 +1,82 @@ +/* ### + * 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; + +import java.awt.*; + +import org.apache.commons.lang3.StringUtils; + +import generic.theme.GColor; +import generic.theme.GThemeDefaults.Colors.Palette; +import generic.theme.Gui; + +/** + * Class to overlay a message on the decompiler panel to indicate the display is stale and + * needs to be refreshed manually. + */ +class OverlayMessagePainter { + private static final int MARGIN = 10; + private static final String FONT_ID = "font.graph.component.message"; + private final Color gradientColor = new GColor("color.bg.visualgraph.message"); + private String message; + + void setMessage(String message) { + this.message = message; + } + + boolean isActive() { + return !StringUtils.isBlank(message); + } + + void paintOverlay(Graphics g, Rectangle bounds) { + if (!isActive()) { + return; + } + + Graphics2D g2 = (Graphics2D) g; + + // this composite softens the text and color of the message + Composite originalComposite = g2.getComposite(); + g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SrcOver.getRule(), .60f)); + + // set up font + Font font = Gui.getFont(FONT_ID); + g.setFont(font); + Rectangle textBounds = font.getStringBounds(message, g2.getFontRenderContext()).getBounds(); + + int gh = textBounds.height * 3; + int gy = bounds.height - gh; + paintGradient(g2, 0, gy, bounds.width, gh); + + // paint message + g2.setPaint(Palette.BLACK); + int textX = bounds.width - textBounds.width - MARGIN; + int textY = bounds.height - textBounds.height / 2; //text at bottom; account for baseline + g2.drawString(message, textX, textY); + + g2.setComposite(originalComposite); + } + + private void paintGradient(Graphics2D g2, int x, int y, int w, int h) { + Color[] colors = new Color[] { Color.WHITE, gradientColor }; + float[] fractions = new float[] { 0.0f, .95f }; + LinearGradientPaint gradiantPaint = + new LinearGradientPaint(new Point(x, y), new Point(x, y + h), fractions, colors); + g2.setPaint(gradiantPaint); + g2.fillRect(x, y, w, h); + } + +} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/test/AbstractDockingTest.java b/Ghidra/Framework/Docking/src/main/java/docking/test/AbstractDockingTest.java index 5581efdc68..33594248df 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/test/AbstractDockingTest.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/test/AbstractDockingTest.java @@ -1300,6 +1300,9 @@ public abstract class AbstractDockingTest extends AbstractGuiTest { assertNotNull("Action cannot be null", action); assertNotNull("Action context cannot be null", context); + boolean isValid = runSwing(() -> action.isValidContext(context)); + assertTrue("Attempted to invoke action with invalid context", isValid); + runSwing(() -> { action.isAddToPopup(context); diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/indexedscrollpane/IndexedScrollPane.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/indexedscrollpane/IndexedScrollPane.java index aa38110e79..a9aab9ba42 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/indexedscrollpane/IndexedScrollPane.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/indexedscrollpane/IndexedScrollPane.java @@ -119,6 +119,10 @@ public class IndexedScrollPane extends JPanel implements IndexScrollListener { return new Dimension(comp.getPreferredSize().width, indexMapper.getViewHeight()); } + public Dimension getViewExtentSize() { + return viewport.getExtentSize(); + } + public void viewportStateChanged() { Dimension extentSize = viewport.getExtentSize(); if (!extentSize.equals(visibleSize)) { @@ -241,7 +245,24 @@ public class IndexedScrollPane extends JPanel implements IndexScrollListener { @Override public boolean getScrollableTracksViewportWidth() { - return false; + int prefWidth = comp.getPreferredSize().width; + int scrollPaneWidth = getScrollPaneWidth(); + return scrollPaneWidth > prefWidth; + } + + private int getScrollPaneWidth() { + Container myParent = getParent(); + if (myParent == null) { + return 0; + } + if (myParent instanceof JViewport vp) { + return vp.getExtentSize().width; + } + Container grandParent = myParent.getParent(); + if (grandParent == null) { + return 0; + } + return grandParent.getSize().width; } @Override diff --git a/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/DecompilePluginScreenShots.java b/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/DecompilePluginScreenShots.java index aef20a0f55..d21430ae31 100644 --- a/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/DecompilePluginScreenShots.java +++ b/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/DecompilePluginScreenShots.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. @@ -24,9 +24,13 @@ import org.junit.Test; import docking.ComponentProvider; import docking.DockableComponent; +import docking.widgets.fieldpanel.FieldPanel; +import docking.widgets.fieldpanel.support.FieldLocation; import generic.theme.GThemeDefaults.Colors.Palette; +import ghidra.app.decompiler.component.DecompilerPanel; import ghidra.app.plugin.core.codebrowser.CodeViewerProvider; import ghidra.app.plugin.core.datamgr.DataTypesProvider; +import ghidra.app.plugin.core.decompile.DecompilerProvider; import ghidra.app.plugin.core.programtree.ViewManagerComponentProvider; public class DecompilePluginScreenShots extends GhidraScreenShotGenerator { @@ -115,52 +119,6 @@ public class DecompilePluginScreenShots extends GhidraScreenShotGenerator { image = tf.getImage(); } - @Test - public void testBackwardSlice() { - TextFormatter tf = new TextFormatter(16, 500, 4, 5, 0); - TextFormatterContext hl = new TextFormatterContext(Palette.BLACK, Palette.YELLOW); - TextFormatterContext red = new TextFormatterContext(Palette.RED, Palette.WHITE); - TextFormatterContext blue = new TextFormatterContext(Palette.BLUE, Palette.WHITE); - TextFormatterContext green = new TextFormatterContext(Palette.GREEN, Palette.WHITE); - TextFormatterContext greenhl = new TextFormatterContext(Palette.GREEN, Palette.YELLOW); - TextFormatterContext cursorhl = - new TextFormatterContext(Palette.BLACK, Palette.YELLOW, Palette.RED); - - tf.writeln(" |a| = |psParm2|->id;", hl, hl); - tf.writeln(" b = |max_alpha|(|psParm1|->next,|psParm1|->id);", red, hl, hl); - tf.writeln(" c = |max_beta|(psParm1->prev, |a|);", red, hl); - tf.writeln(" c = c + b;"); - tf.writeln(" dStack8 = |0|;", green); - tf.writeln(" |while| (psParm1->count != dStack8 && (sdword)dStack8) {", blue); - tf.writeln(" |if| (c < (sdword)(dStack8 + b)) {", blue); - tf.writeln(" c = c + |a|;", hl); - tf.writeln(" }"); - tf.writeln(" |else| {", blue); - tf.writeln(" |a| = |a| + |10|;", hl, hl, greenhl); - tf.writeln(" }"); - tf.writeln(" dStack8 = dStack8 + |1|;", green); - tf.writeln(" }"); - tf.writeln(" psParm1->count = |a| + c;", cursorhl); - tf.writeln(" |return|;", blue); - - image = tf.getImage(); - } - - @Test - public void testStructnotapplied() { - Image listingImage = getListingImage(); - Image decompImage = getDecompilerNoStructImage(); - int listingWidth = listingImage.getWidth(null); - int decompWidth = decompImage.getWidth(null); - int height = Math.max(listingImage.getHeight(null), decompImage.getHeight(null)); - BufferedImage combined = createEmptyImage(listingWidth + decompWidth, height); - Graphics2D g = combined.createGraphics(); - g.drawImage(listingImage, 0, 0, null); - g.drawImage(decompImage, listingWidth, 0, null); - g.dispose(); - image = combined; - } - public void testStructApplied() { Image listingImage = getListingImage(); Image decompImage = getDecompilerStructAppliedImage(); @@ -177,14 +135,37 @@ public class DecompilePluginScreenShots extends GhidraScreenShotGenerator { @Test public void testEditFunctionSignature() { + DecompilerProvider provider = (DecompilerProvider) getProvider("Decompiler"); + showProvider(provider.getClass()); goToListing(0x401040); - ComponentProvider provider = getProvider("Decompiler"); + int line = 2; // function signature line + int charPos = 15; // function name + setDecompilerLocation(provider, line, charPos); showProvider(provider.getClass()); waitForSwing(); performAction("Edit Function Signature", "DecompilePlugin", provider, false); captureDialog(); } + private void setDecompilerLocation(DecompilerProvider provider, int line, int charPosition) { + + DecompilerPanel panel = provider.getDecompilerPanel(); + FieldPanel fp = panel.getFieldPanel(); + FieldLocation loc = loc(line, charPosition); + + // scroll to the field to make sure it has been built so that we can get its point + fp.scrollTo(loc); + Point p = fp.getPointForLocation(loc); + + click(fp, p, 1, true); + waitForSwing(); + } + + private FieldLocation loc(int lineNumber, int col) { + FieldLocation loc = new FieldLocation(lineNumber - 1, 0, 0, col); + return loc; + } + private Image getListingImage() { Font font = new Font("Monospaced", Font.PLAIN, 12);