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 4cf79e60df..fb2e9d8894 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 @@ -16,6 +16,7 @@ package ghidra.features.base.memsearch.gui; import java.awt.*; +import java.time.Duration; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -29,6 +30,7 @@ import docking.action.DockingAction; import docking.action.ToggleDockingAction; import docking.action.builder.ActionBuilder; import docking.action.builder.ToggleActionBuilder; +import docking.util.GGlassPaneMessage; import generic.theme.GIcon; import ghidra.app.context.NavigatableActionContext; import ghidra.app.nav.Navigatable; @@ -100,6 +102,9 @@ public class MemorySearchProvider extends ComponentProviderAdapter private SearchGuiModel model; private boolean isPrivate = false; + // used to show a temporary message over the table + private GGlassPaneMessage glassPaneMessage; + public MemorySearchProvider(MemorySearchPlugin plugin, Navigatable navigatable, SearchSettings settings, MemorySearchOptions options, SearchHistory history) { super(plugin.getTool(), "Memory Search", plugin.getName()); @@ -355,9 +360,9 @@ public class MemorySearchProvider extends ComponentProviderAdapter setBusy(false); updateSubTitle(); if (!cancelled && terminatedEarly) { - Msg.showInfo(getClass(), resultsPanel, "Search Limit Exceeded!", - "Stopped search after finding " + options.getSearchLimit() + " matches.\n" + - "The search limit can be changed at Edit->Tool Options, under Search."); + showAlert("Search Limit Exceeded!\n\nStopped search after finding " + + options.getSearchLimit() + " matches.\n" + + "The search limit can be changed at Edit \u2192 Tool Options, under Search."); } else if (!foundResults) { @@ -545,6 +550,8 @@ public class MemorySearchProvider extends ComponentProviderAdapter } private void dispose() { + glassPaneMessage.hide(); + glassPaneMessage = null; matchHighlighter.dispose(); USED_IDS.remove(id); if (navigatable != null) { @@ -638,12 +645,6 @@ public class MemorySearchProvider extends ComponentProviderAdapter return byteMatcher.getDescription(); } - void showAlert(String alertMessage) { - // replace with water mark concept - Toolkit.getDefaultToolkit().beep(); - Msg.showInfo(this, null, "Search Results", alertMessage); - } - @Override protected ActionContext createContext(Component sourceComponent, Object contextObject) { ActionContext context = new NavigatableActionContext(this, navigatable); @@ -652,4 +653,16 @@ public class MemorySearchProvider extends ComponentProviderAdapter return context; } + private void showAlert(String message) { + Toolkit.getDefaultToolkit().beep(); + + if (glassPaneMessage == null) { + GhidraTable table = resultsPanel.getTable(); + glassPaneMessage = new GGlassPaneMessage(table); + glassPaneMessage.setHideDelay(Duration.ofSeconds(3)); + } + + glassPaneMessage.showCenteredMessage(message); + } + } diff --git a/Ghidra/Framework/Docking/data/docking.theme.properties b/Ghidra/Framework/Docking/data/docking.theme.properties index ddaa0432c9..6ae7b334bd 100644 --- a/Ghidra/Framework/Docking/data/docking.theme.properties +++ b/Ghidra/Framework/Docking/data/docking.theme.properties @@ -15,6 +15,8 @@ color.fg.dialog.status.error = color.fg.messages.error color.fg.dialog.status.normal = color.fg.messages.normal color.fg.dialog.status.warning = color.fg.messages.warning +color.fg.glasspane.message = color.palette.lightcornflowerblue + color.bg.selection = color.palette.palegreen color.bg.highlight = color.palette.lemonchiffon @@ -125,9 +127,6 @@ icon.widget.tabs.empty.small = empty8x16.png icon.widget.tabs.close = x.gif icon.widget.tabs.close.highlight = pinkX.gif icon.widget.tabs.list = VCRFastForward.gif -font.widget.tabs.selected = sansserif-plain-11 -font.widget.tabs = sansserif-plain-11 -font.widget.tabs.list = sansserif-bold-9 icon.dialog.error.expandable.report = icon.spreadsheet icon.dialog.error.expandable.exception = program_obj.png @@ -157,6 +156,10 @@ icon.task.progress.hourglass.11 = hourglass24_11.png // Fonts +font.input.hint = monospaced-plain-10 + +font.glasspane.message = sansserif-bold-24 + font.splash.header.default = serif-bold-35 font.splash.status = serif-bold-12 @@ -164,12 +167,13 @@ font.splash.status = serif-bold-12 font.table.base = [font]system.font.control font.table.header.number = arial-bold-12 -font.input.hint = monospaced-plain-10 - font.task.monitor.label.message = sansserif-plain-10 -font.wizard.border.title = sansserif-plain-10 +font.widget.tabs.selected = sansserif-plain-11 +font.widget.tabs = sansserif-plain-11 +font.widget.tabs.list = sansserif-bold-9 +font.wizard.border.title = sansserif-plain-10 diff --git a/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationPainter.java b/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationPainter.java index 27bb0f6af0..24043506e4 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationPainter.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationPainter.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. @@ -16,10 +15,10 @@ */ package docking.util; -import ghidra.util.bean.GGlassPane; - import java.awt.Graphics; +import ghidra.util.bean.GGlassPane; + /** * An interface used with {@link AnimationUtils} to allow clients to use the timing * framework while performing their own painting. @@ -31,7 +30,7 @@ public interface AnimationPainter { * * @param glassPane the glass pane upon which painting takes place * @param graphics the graphics used to paint - * @param percentComplete a value from 0 to 1, 1 being fully complete. + * @param value a value from from the range supplied to the animator when it was created */ - public void paint(GGlassPane glassPane, Graphics graphics, double percentComplete); + public void paint(GGlassPane glassPane, Graphics graphics, double value); } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationRunner.java b/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationRunner.java new file mode 100644 index 0000000000..47b7864245 --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationRunner.java @@ -0,0 +1,256 @@ +/* ### + * 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.util; + +import java.awt.Graphics; +import java.time.Duration; +import java.util.Objects; + +import javax.swing.JComponent; + +import org.jdesktop.animation.timing.Animator; +import org.jdesktop.animation.timing.TimingTargetAdapter; +import org.jdesktop.animation.timing.interpolation.PropertySetter; + +import ghidra.util.Msg; +import ghidra.util.bean.GGlassPane; +import ghidra.util.bean.GGlassPanePainter; +import utility.function.Callback; +import utility.function.Dummy; + +/** + * A class that does basic setup work for creating an {@link Animator}. The animator will run a + * timer in a background thread, calling the client periodically until the animation progress is + * finished. The actual visual animation is handled by the client's {@link AnimationPainter}. + * This class is provided for convenience. Clients can create their own {@link Animator} as needed. + *

+ * A {@link #setPainter(AnimationPainter) painter} must be supplied before calling {@link #start()}. + * A simple example usage: + *

+ *  GTable table = ...;
+ *  AnimationPainter painter = new AnimationPainter() {
+ *  	public void paint(GGlassPane glassPane, Graphics graphics, double value) {
+ *  		
+ *  		// repaint some contents to the glass pane's graphics using the current value as to 
+ *  		// know where we are in the progress of animating
+ *  	}
+ *  };
+ *  AnimationRunner animation = new AnimationRunner(table);
+ *  animation.setPainter(painter);
+ *  animation.start();
+ *  
+ *  ...
+ *  
+ *  // code to stop animation, such as when a request for a new animation is received
+ *  if (animation != null) {
+ *  	animation.stop();
+ *  }
+ *  
+ * 
+ *

+ * Clients who wish to perform more configuration can call {@link #createAnimator()} to perform the + * basic setup, calling {@link #start()} when finished with any follow-up configuration. + *

+ * See {@link Animator} for details on the animation process. + */ +public class AnimationRunner { + + private JComponent component; + private GGlassPane glassPane; + private Animator animator; + private UserDefinedPainter painter; + private boolean removePainterWhenFinished = true; + private Callback doneCallback = Callback.dummy(); + + private Duration duration = Duration.ofSeconds(1); + private Double[] values = new Double[] { 0D, 1D }; + + public AnimationRunner(JComponent component) { + this.component = component; + } + + /** + * Sets the painter required for the animator to work. + * @param animationPainter the painter. + */ + public void setPainter(AnimationPainter animationPainter) { + this.painter = new UserDefinedPainter(animationPainter); + } + + /** + * Sets the values passed to the animator created by this class. These values will be split + * into a range of values, broken up by the duration of the animator. The default values are 0 + * and 1. + *

+ * See {@link PropertySetter#createAnimator(int, Object, String, Object...)}. + * @param values the values + */ + public void setValues(Double... values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("'values' cannot be null or empty"); + } + this.values = values; + } + + /** + * Signals to remove the painter from the glass pane when the animation is finished. Clients + * can specify {@code false} which will allow the painting to continue after the animation has + * finished. + * @param b true to remove the painter. The default value is true. + */ + public void setRemovePainterWhenFinished(boolean b) { + this.removePainterWhenFinished = b; + } + + /** + * Sets a callback to be called when the animation is finished. + * @param c the callback + */ + public void setDoneCallback(Callback c) { + this.doneCallback = Dummy.ifNull(c); + } + + /** + * Sets the animation duration. The default is 1 second. + * @param duration the duration + */ + public void setDuration(Duration duration) { + this.duration = Objects.requireNonNull(duration); + } + + /** + * This is a method used by the animator. Clients should not call this method. + * @param value the current value created by the animator + */ + public void setCurrentValue(Double value) { + painter.setValue(value); + glassPane.repaint(); + } + + /** + * Creates the animator used to perform animation. Clients will call {@link Animator#start()} + * to begin animation. Many attributes of the animator can be configured before starting. + * @return the animator + * @throws IllegalStateException if require values have not been set on this class, such as + * {@link #setValues(Double...)} or {@link #setPainter(AnimationPainter)}. + */ + public Animator createAnimator() { + + if (animator != null) { + return animator; + } + + glassPane = AnimationUtils.getGlassPane(component); + if (glassPane == null) { + Msg.debug(AnimationUtils.class, + "Cannot animate without a " + GGlassPane.class.getName() + " installed"); + throw new IllegalStateException("Unable to find Glass Pane"); + } + + if (painter == null) { + throw new IllegalStateException("A painter must be supplied"); + } + + setCurrentValue(values[0]); // set initial value + + int aniationDuration = (int) duration.toMillis(); + if (!AnimationUtils.isAnimationEnabled()) { + aniationDuration = 0; // do not animate + } + + animator = PropertySetter.createAnimator(aniationDuration, this, "currentValue", values); + animator.setAcceleration(0.2f); + animator.setDeceleration(0.8f); + animator.addTarget(new TimingTargetAdapter() { + @Override + public void end() { + done(); + } + }); + + return animator; + } + + /** + * Starts the animation process, creating the animator as needed. This method can be called + * repeatedly without calling stop first. + */ + public void start() { + + if (painter == null) { + throw new IllegalStateException("A painter must be supplied"); + } + + if (animator != null) { + if (animator.isRunning()) { + animator.stop(); + } + } + else { + animator = createAnimator(); + } + + glassPane.addPainter(painter); + animator.start(); + } + + /** + * Stops all animation and removes the painter from the glass pane. {@link #start()} can be + * called again after calling this method. + */ + public void stop() { + if (animator != null) { + animator.stop(); + } + + glassPane.removePainter(painter); + } + + private void done() { + setCurrentValue(values[values.length - 1]); + + if (removePainterWhenFinished) { + glassPane.removePainter(painter); + } + else { + glassPane.repaint(); + } + + doneCallback.call(); + } + + /** + * A painter that will call the user-supplied painter with the current value. + */ + private class UserDefinedPainter implements GGlassPanePainter { + + private AnimationPainter userPainter; + private double value; + + UserDefinedPainter(AnimationPainter userPainter) { + this.userPainter = userPainter; + } + + void setValue(double value) { + this.value = value; + } + + @Override + public void paint(GGlassPane gp, Graphics graphics) { + userPainter.paint(gp, graphics, value); + } + } +} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationUtils.java b/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationUtils.java index 879865a2ce..5f6b8883be 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationUtils.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/util/AnimationUtils.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. @@ -133,24 +133,19 @@ public class AnimationUtils { return driver.animator; } - public static Animator createPaintingAnimator(Component window, AnimationPainter painter) { + public static Animator createPaintingAnimator(Component component, AnimationPainter painter) { if (!animationEnabled) { return null; } - Component paneComponent = getGlassPane(window); - if (paneComponent == null) { + GGlassPane glassPane = getGlassPane(component); + if (glassPane == null) { // could happen if the given component has not yet been realized + Msg.debug(AnimationUtils.class, + "Cannot animate without a " + GGlassPane.class.getName() + " installed"); return null; } - if (!(paneComponent instanceof GGlassPane)) { - Msg.debug(AnimationUtils.class, - "Cannot animate without a " + GGlassPane.class.getName() + " installed"); - return null; // shouldn't happen - } - - GGlassPane glassPane = (GGlassPane) paneComponent; BasicAnimationDriver driver = new BasicAnimationDriver(glassPane, new UserDefinedPainter(painter)); return driver.animator; @@ -596,6 +591,8 @@ public class AnimationUtils { animator = PropertySetter.createAnimator(2000, this, "percentComplete", start, max); + painter.setPercentComplete(1D); // set initial value + animator.setAcceleration(0.2f); animator.setDeceleration(0.8f); animator.addTarget(new TimingTargetAdapter() { diff --git a/Ghidra/Framework/Docking/src/main/java/docking/util/GGlassPaneMessage.java b/Ghidra/Framework/Docking/src/main/java/docking/util/GGlassPaneMessage.java new file mode 100644 index 0000000000..482926e365 --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/util/GGlassPaneMessage.java @@ -0,0 +1,362 @@ +/* ### + * 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.util; + +import java.awt.*; +import java.awt.event.*; +import java.awt.image.BufferedImage; +import java.time.Duration; +import java.util.Objects; + +import javax.swing.*; + +import generic.json.Json; +import generic.theme.Gui; +import generic.util.WindowUtilities; +import generic.util.image.ImageUtils; +import ghidra.util.Swing; +import ghidra.util.bean.GGlassPane; +import ghidra.util.timer.GTimer; +import ghidra.util.timer.GTimerMonitor; + +/** + * A class that allows clients to paint a message over top of a given component. + *

+ * This class will honor newline characters and will word wrap as needed. If the message being + * displayed will not fit within the bounds of the given component, then the text will be clipped. + */ +public class GGlassPaneMessage { + + private static final int HIDE_DELAY_MILLIS = 2000; + + private AnimationRunner animationRunner; + private GTimerMonitor timerMonitor; + private Duration hideDelay = Duration.ofMillis(HIDE_DELAY_MILLIS); + + private JComponent component; + private String message; + + public GGlassPaneMessage(JComponent component) { + this.component = component; + + component.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { + if (animationRunner != null) { + hide(); + e.consume(); + } + } + } + }); + + } + + /** + * Sets the amount of time the message will remain on screen after the animation has completed. + * To hide the message sooner, call {@link #hide()}. + * @param duration the duration + */ + public void setHideDelay(Duration duration) { + hideDelay = Objects.requireNonNull(duration); + } + + /** + * Shows the given message centered over the component used by this class. + * @param newMessage the message + */ + public void showCenteredMessage(String newMessage) { + AnimationPainter painter = new CenterTextPainter(); + showMessage(newMessage, painter); + } + + /** + * Shows a message at the bottom of the component used by this class. + * @param newMessage the message + */ + public void showBottomMessage(String newMessage) { + AnimationPainter painter = new BottomTextPainter(); + showMessage(newMessage, painter); + } + + public void showMessage(String newMessage, AnimationPainter painter) { + + hide(); + + this.message = Objects.requireNonNull(newMessage); + + AnimationRunner runner = new AnimationRunner(component); + + double full = 1D; + double emphasized = 1.2D; + Double[] stages = new Double[] { full, emphasized, emphasized, emphasized, full }; + runner.setValues(stages); + runner.setDuration(Duration.ofMillis(500)); + runner.setRemovePainterWhenFinished(false); // we will remove it ourselves + runner.setPainter(painter); + runner.start(); + + animationRunner = runner; + + // remove the text later so users have a chance to read it + timerMonitor = GTimer.scheduleRunnable(hideDelay.toMillis(), () -> { + Swing.runNow(() -> hide()); + }); + } + + /** + * Hides any message being displayed. This can be called even if the message has been hidden. + */ + public void hide() { + + if (animationRunner != null) { + animationRunner.stop(); + animationRunner = null; + } + + if (timerMonitor != null) { + timerMonitor.cancel(); + timerMonitor = null; + } + } + + @Override + public String toString() { + return Json.toString(message); + } + +//================================================================================================= +// Inner Classes +//================================================================================================= + + private abstract class AbstractTextPainer implements AnimationPainter { + + private static String FONT_ID = "font.glasspane.message"; + private static final String MESSAGE_FG_COLOR_ID = "color.fg.glasspane.message"; + + // use an image of the painted text to make scaling smoother; cache the image for speed + protected Image baseTextImage; + + private ComponentListener resizeListener = new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + baseTextImage = null; + Window w = WindowUtilities.windowForComponent(component); + w.repaint(); + } + }; + + AbstractTextPainer() { + component.addComponentListener(resizeListener); + } + + private void createImage() { + + if (baseTextImage != null) { + return; + } + + Font font = Gui.getFont(FONT_ID); + BufferedImage tempImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB); + Graphics2D scratchG2d = (Graphics2D) tempImage.getGraphics(); + + scratchG2d.setFont(font); + Dimension size = getComponentSize(); + int padding = 20; + size.width -= padding; + TextShaper textShaper = new TextShaper(message, size, scratchG2d); + + Dimension textSize = textShaper.getTextSize(); + if (textSize.width == 0 || textSize.height == 0) { + return; // not enough room to paint text + } + + // Add some space to handle float to int rounding in the text calculation. This prevents + // the edge of characters from getting clipped when painting. + int roundingPadding = 5; + int w = textSize.width + roundingPadding; + int h = textSize.height; + + BufferedImage bi = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = (Graphics2D) bi.getGraphics(); + g2d.setFont(font); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + + g2d.setColor(Gui.getColor(MESSAGE_FG_COLOR_ID)); + + textShaper.drawText(g2d); + + g2d.dispose(); + + baseTextImage = bi; + } + + protected Dimension getComponentSize() { + Rectangle r = component.getVisibleRect(); + Dimension size = r.getSize(); + Container parent = component.getParent(); + if (parent instanceof JScrollPane || parent instanceof JViewport) { + // this handles covering the component when it is inside of a scroll pane + size = parent.getSize(); + } + return size; + } + + protected Rectangle getComponentBounds(GGlassPane glassPane) { + + Rectangle r = component.getVisibleRect(); + Point point = r.getLocation(); + Dimension size = r.getSize(); + + Container parent = component.getParent(); + Component coordinateSource = parent; + if (parent instanceof JScrollPane || parent instanceof JViewport) { + // this handles covering the component when it is inside of a scroll pane + point = parent.getLocation(); + size = parent.getSize(); + coordinateSource = parent.getParent(); + } + + point = SwingUtilities.convertPoint(coordinateSource, point, glassPane); + return new Rectangle(point, size); + } + + protected Image updateImage(Graphics2D g2d, double scale) { + + baseTextImage = null; + + createImage(); + + if (baseTextImage == null) { + return null; // this implies an exception happened + } + + int w = baseTextImage.getWidth(null); + int h = baseTextImage.getHeight(null); + + int sw = ((int) (w * scale)); + int sh = ((int) (h * scale)); + + int iw = baseTextImage.getWidth(null); + int ih = baseTextImage.getHeight(null); + + if (iw == sw && ih == sh) { + return baseTextImage; // nothing to change + } + + return ImageUtils.createScaledImage(baseTextImage, sw, sh, 0); + } + + protected void paintOverComponent(Graphics2D g2d, GGlassPane glassPane) { + + Rectangle bounds = getComponentBounds(glassPane); + + float alpha = .7F; // arbitrary; allow some of the background to be visible + AlphaComposite alphaComposite = AlphaComposite + .getInstance(AlphaComposite.SrcOver.getRule(), alpha); + Composite originalComposite = g2d.getComposite(); + Color originalColor = g2d.getColor(); + g2d.setComposite(alphaComposite); + g2d.setColor(component.getBackground()); + + g2d.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); + + g2d.setComposite(originalComposite); + g2d.setColor(originalColor); + } + } + + private class CenterTextPainter extends AbstractTextPainer { + + @Override + public void paint(GGlassPane glassPane, Graphics graphics, double intensity) { + + Graphics2D g2d = (Graphics2D) graphics; + + Image image = updateImage(g2d, intensity); + if (image == null) { + return; // this implies an exception happened + } + + // use visible rectangle to get the correct size when in a scroll pane + Rectangle componentBounds = getComponentBounds(glassPane); + + // without room to draw the message, skip so we don't draw over other components + int imageHeight = image.getHeight(null); + if (imageHeight > componentBounds.height) { + return; + } + + paintOverComponent(g2d, glassPane); + + // note: textHeight and textWidth will vary depending on the intensity + int textHeight = image.getHeight(null); + int textWidth = image.getWidth(null); + int padding = 5; + int middleY = componentBounds.y + (componentBounds.height / 2); + int middleX = componentBounds.x + (componentBounds.width / 2); + int requiredHeight = textHeight + padding; + int requiredWidth = textWidth + padding; + int y = middleY - (requiredHeight / 2); + int x = middleX - (requiredWidth / 2); + + g2d.drawImage(image, x, y, null); + + // debug + // g2d.setColor(Palette.BLUE); + // g2d.drawRect(x, y, textWidth, textHeight); + } + + } + + private class BottomTextPainter extends AbstractTextPainer { + + @Override + public void paint(GGlassPane glassPane, Graphics graphics, double intensity) { + + Graphics2D g2d = (Graphics2D) graphics; + + Image image = updateImage(g2d, intensity); + if (image == null) { + return; // this implies an exception happened + } + + // use visible rectangle to get the correct size when in a scroll pane + Rectangle componentBounds = getComponentBounds(glassPane); + + // without room to draw the message, skip so we don't draw over other components + int imageHeight = image.getHeight(null); + if (imageHeight > componentBounds.height) { + return; + } + + paintOverComponent(g2d, glassPane); + + int textHeight = image.getHeight(null); + int padding = 5; + int bottom = componentBounds.y + componentBounds.height; + int requiredHeight = textHeight + padding; + int y = bottom - requiredHeight; + int x = componentBounds.x + padding; + + g2d.drawImage(image, x, y, null); + + } + + } +} diff --git a/Ghidra/Framework/Docking/src/main/java/docking/util/TextShaper.java b/Ghidra/Framework/Docking/src/main/java/docking/util/TextShaper.java new file mode 100644 index 0000000000..d041dd563e --- /dev/null +++ b/Ghidra/Framework/Docking/src/main/java/docking/util/TextShaper.java @@ -0,0 +1,340 @@ +/* ### + * 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.util; + +import java.awt.*; +import java.awt.font.*; +import java.text.AttributedCharacterIterator; +import java.text.AttributedString; +import java.util.*; +import java.util.List; + +/** + * A class that will layout text into lines based on the given display size. This class requires + * the graphics context in order to correctly size the text. + */ +public class TextShaper { + + private List lines = new ArrayList<>(); + private Dimension textSize = new Dimension(0, 0); + + private String originalText; + private String clippedText; + private Dimension displaySize = new Dimension(0, 0); + private Graphics2D g2d; + + /** + * Creates a text shaper with the given text, display size and graphics context. + * @param text the text + * @param displaySize the size + * @param g2d the graphics + */ + public TextShaper(String text, Dimension displaySize, Graphics2D g2d) { + this.originalText = text; + this.clippedText = text; + this.displaySize = displaySize; + this.g2d = g2d; + + // Trim blank lines we don't want + // Drop all blank lines before and after the non-blank lines. It seems pointless to paint + // these blank lines. We can change this if there is a valid reason to do so. + text = removeNewlinesAroundText(text); + text = text.replaceAll("\t", " "); + + init(text); + } + + private String removeNewlinesAroundText(String s) { + int first = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c != '\n') { + first = i; + break; + } + } + + s = s.substring(first); + + int last = s.length() - 1; + for (int i = last; i >= 0; i--) { + char c = s.charAt(i); + if (c != '\n') { + last = i; + break; + } + } + return s.substring(0, last + 1); + } + + private void init(String currentText) { + + if (displaySize.width <= 0) { + return; + } + + // create the attributed string needed by the LineBreakMeasurer, setting the font over all + // of the text + AttributedString as = new AttributedString(currentText); + int length = currentText.length(); + Font font = g2d.getFont(); + as.addAttribute(TextAttribute.FONT, font, 0, length); + + // create the LineBreakMeasuerer we will use to split the text at the given width, using the + // rendering environment to get accurate size information + AttributedCharacterIterator paragraph = as.getIterator(); + FontRenderContext frc = g2d.getFontRenderContext(); + LineBreakMeasurer measurer = new LineBreakMeasurer(paragraph, frc); + measurer.setPosition(paragraph.getBeginIndex()); + + int totalHeight = 0; + int largestWidth = 0; + int position = 0; + while ((position = measurer.getPosition()) < paragraph.getEndIndex()) { + + TextShaperLine line = createLine(currentText, measurer); + + // Look ahead to see if the new row we created will fit within the height restrictions. + // If not, we must clip the text and do this work again. + float rowHeight = line.getHeight(); + totalHeight += rowHeight; + if (totalHeight > displaySize.height) { + + // Truncate the original text and try again with the smaller text that we now know + // will fit, adding an ellipsis. + int lineCount = lines.size(); + lines.clear(); + + if (lineCount == 0) { + return; // no room for a single line of text + } + + // clip the text of the and recalculate + int end = position; + int newEnd = end - 3; // 3 for '...' + clippedText = currentText.substring(0, newEnd) + "..."; + + init(clippedText); + return; + } + + lines.add(line); + + largestWidth = Math.max(largestWidth, (int) line.getWidth()); + + } + + textSize = new Dimension(largestWidth, totalHeight); + } + + private TextShaperLine createLine(String currentText, LineBreakMeasurer measurer) { + + // nextOffset() finds the end of the text that fits into the max width + int position = measurer.getPosition(); + int wrappingWidth = displaySize.width; + int nextEnd = measurer.nextOffset(wrappingWidth); + + // special case: look for newlines in the current line and split the text on that + // newline instead so that user-requested newlines are painted + int limit = updateLimitForNewline(currentText, position, nextEnd); + + TextShaperLine line = null; + if (limit == 0) { + // A limit of 0 implies the first character of the text is a newline. Add a full blank + // line to handle that case. This can happen with consecutive newlines or if a line + // happened to break with a leading newline. + Font font = g2d.getFont(); + FontRenderContext frc = g2d.getFontRenderContext(); + LineMetrics lm = font.getLineMetrics("W", frc); + line = new BlankLine(lm.getHeight()); + + // advance the measurer to move past the single newline + measurer.nextLayout(wrappingWidth, position + 1, false); + } + else { + // create a layout with the given limit (either restricted by width or by a newline) + TextLayout layout = measurer.nextLayout(wrappingWidth, position + limit, false); + int nextPosition = measurer.getPosition(); + String lineText = currentText.substring(position, nextPosition); + line = new TextLayoutLine(lineText, layout); + } + + // If we limited the current line to break on the newline, then move past that newline so it + // is not in the next line we process. Since we have broken the line already, we do not + // need that newline character. + movePastTrailingNewline(currentText, measurer); + + return line; + } + + private int updateLimitForNewline(String text, int position, int limit) { + int newline = text.indexOf('\n', position); + if (newline != -1) { + if (newline >= position && newline < limit) { + // newline will be in the current line; break on the newline + return newline - position; + } + } + return limit; + } + + private void movePastTrailingNewline(String text, LineBreakMeasurer measurer) { + int newPosition = measurer.getPosition(); + if (newPosition < text.length()) { + char nextChar = text.charAt(newPosition); + if (nextChar == '\n') { + measurer.setPosition(newPosition + 1); + } + } + } + + /** + * Returns the bounds of the wrapped text of this class + * @return the bounds of the wrapped text of this class + */ + public Dimension getTextSize() { + return textSize; + } + + /** + * Returns true if the text is too large to fit in the original display size + * @return true if the text is too large to fit in the original display size + */ + public boolean isClipped() { + return !Objects.equals(originalText, clippedText); + } + + public List getLines() { + return Collections.unmodifiableList(lines); + } + + /** + * Renders the wrapped text into the graphics used to create this class. + * @param g the graphics into which the text should be painted. + */ + public void drawText(Graphics2D g) { + float dy = 0; + for (TextShaperLine line : lines) { + float y = dy + line.getAscent(); // move the drawing down to the start of the next line + line.draw(g, 0, y); + dy += line.getHeight(); + } + } + + abstract class TextShaperLine { + abstract float getHeight(); + + abstract float getWidth(); + + abstract float getAscent(); + + abstract String getText(); + + abstract boolean isBlank(); + + abstract void draw(Graphics2D g, float x, float y); + } + + private class TextLayoutLine extends TextShaperLine { + private String lineText; + private TextLayout layout; + + TextLayoutLine(String text, TextLayout layout) { + this.lineText = text; + this.layout = layout; + } + + @Override + float getAscent() { + return layout.getAscent(); + } + + @Override + float getHeight() { + return (int) (layout.getAscent() + layout.getDescent() + layout.getLeading()); + } + + @Override + float getWidth() { + return (float) layout.getBounds().getWidth(); + } + + @Override + String getText() { + return lineText; + } + + @Override + void draw(Graphics2D g, float x, float y) { + layout.draw(g, x, y); + } + + @Override + boolean isBlank() { + return false; + } + + @Override + public String toString() { + return lineText; + } + } + + private class BlankLine extends TextShaperLine { + + private float lineHeight; + + BlankLine(float lineHeight) { + this.lineHeight = lineHeight; + } + + @Override + float getAscent() { + return 0; // the value shouldn't matter, since we don't actually draw anything + } + + @Override + float getHeight() { + return lineHeight; + } + + @Override + float getWidth() { + return 0; + } + + @Override + boolean isBlank() { + return true; + } + + @Override + String getText() { + return "\n"; + } + + @Override + void draw(Graphics2D g, float x, float y) { + // nothing to draw + } + + @Override + public String toString() { + return "Blank Line"; + } + } + +} diff --git a/Ghidra/Framework/Docking/src/test.slow/java/docking/util/TextShaperTest.java b/Ghidra/Framework/Docking/src/test.slow/java/docking/util/TextShaperTest.java new file mode 100644 index 0000000000..e487148591 --- /dev/null +++ b/Ghidra/Framework/Docking/src/test.slow/java/docking/util/TextShaperTest.java @@ -0,0 +1,168 @@ +/* ### + * 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.util; + +import static org.junit.Assert.*; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.List; + +import javax.swing.JLabel; +import javax.swing.JPanel; + +import org.junit.Test; + +import docking.DockingFrame; +import docking.test.AbstractDockingTest; +import docking.util.TextShaper.TextShaperLine; +import generic.theme.Gui; +import ghidra.util.bean.GGlassPane; + +public class TextShaperTest extends AbstractDockingTest { + + // @Test + // for debugging + public void testShowMessage() { + + JLabel label = new JLabel("This is
some text that
spans multiple lines."); + + JPanel panel = new JPanel(new BorderLayout()); + panel.add(label); + + DockingFrame frame = new DockingFrame("Test Frame"); + frame.getContentPane().add(panel); + GGlassPane glassPane = new GGlassPane(); + frame.setGlassPane(glassPane); + frame.setSize(400, 400); + frame.setVisible(true); + + GGlassPaneMessage glassPaneMessage = new GGlassPaneMessage(label); + glassPaneMessage + .showCenteredMessage( + "This is a test and (newline\n\nhere) some more text to reach the width limit. " + + "More text to (tab here\t\t) to come as we type."); + fail(); + } + + @Test + public void testShaper() { + + BufferedImage tempImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB); + Graphics2D scratchG2d = (Graphics2D) tempImage.getGraphics(); + Font font = Gui.getFont("font.monospaced").deriveFont(24); + scratchG2d.setFont(font); + + Dimension size = new Dimension(1000, 100); + String message = "This is a message"; + TextShaper shaper = new TextShaper(message, size, scratchG2d); + + List lines = shaper.getLines(); + assertEquals(1, lines.size()); + assertFalse(shaper.isClipped()); + } + + @Test + public void testShaper_LineWrap() { + + BufferedImage tempImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB); + Graphics2D scratchG2d = (Graphics2D) tempImage.getGraphics(); + Font font = Gui.getFont("font.monospaced").deriveFont(24); + scratchG2d.setFont(font); + + Dimension size = new Dimension(100, 100); + String message = "This is a long message"; + TextShaper shaper = new TextShaper(message, size, scratchG2d); + + List lines = shaper.getLines(); + assertEquals(2, lines.size()); + assertFalse(shaper.isClipped()); + } + + @Test + public void testShaper_NewLine() { + + BufferedImage tempImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB); + Graphics2D scratchG2d = (Graphics2D) tempImage.getGraphics(); + Font font = Gui.getFont("font.monospaced").deriveFont(24); + scratchG2d.setFont(font); + + Dimension size = new Dimension(1000, 100); + String message = "This is a long\nmessage"; + TextShaper shaper = new TextShaper(message, size, scratchG2d); + + List lines = shaper.getLines(); + assertEquals(2, lines.size()); + assertEquals("This is a long", lines.get(0).getText()); + assertEquals("message", lines.get(1).getText()); + assertFalse(shaper.isClipped()); + } + + @Test + public void testShaper_NewLines_Consecutive() { + + BufferedImage tempImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB); + Graphics2D scratchG2d = (Graphics2D) tempImage.getGraphics(); + Font font = Gui.getFont("font.monospaced").deriveFont(24); + scratchG2d.setFont(font); + + Dimension size = new Dimension(1000, 100); + String message = "This is a long\n\nmessage"; + TextShaper shaper = new TextShaper(message, size, scratchG2d); + + List lines = shaper.getLines(); + assertEquals(3, lines.size()); + assertEquals("This is a long", lines.get(0).getText()); + assertEquals("\n", lines.get(1).getText()); + assertEquals("message", lines.get(2).getText()); + assertFalse(shaper.isClipped()); + } + + @Test + public void testShaper_NewLines_AroundText() { + + BufferedImage tempImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB); + Graphics2D scratchG2d = (Graphics2D) tempImage.getGraphics(); + Font font = Gui.getFont("font.monospaced").deriveFont(24); + scratchG2d.setFont(font); + + Dimension size = new Dimension(1000, 100); + String message = "\n\nThis is a long message\n\n"; + TextShaper shaper = new TextShaper(message, size, scratchG2d); + + List lines = shaper.getLines(); + assertEquals(1, lines.size()); + assertFalse(shaper.isClipped()); + } + + @Test + public void testShaper_Tabs() { + + BufferedImage tempImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB); + Graphics2D scratchG2d = (Graphics2D) tempImage.getGraphics(); + Font font = Gui.getFont("font.monospaced").deriveFont(24); + scratchG2d.setFont(font); + + Dimension size = new Dimension(1000, 100); + String message = "This is a\t\tmessage"; + TextShaper shaper = new TextShaper(message, size, scratchG2d); + + List lines = shaper.getLines(); + assertEquals(1, lines.size()); + assertEquals("This is a message", lines.get(0).getText()); + assertFalse(shaper.isClipped()); + } +} diff --git a/Ghidra/Framework/Generic/src/main/java/generic/timer/GhidraTimerFactory.java b/Ghidra/Framework/Generic/src/main/java/generic/timer/GhidraTimerFactory.java index db284049b7..eb09db8dfc 100644 --- a/Ghidra/Framework/Generic/src/main/java/generic/timer/GhidraTimerFactory.java +++ b/Ghidra/Framework/Generic/src/main/java/generic/timer/GhidraTimerFactory.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. @@ -16,51 +15,25 @@ */ package generic.timer; -import ghidra.util.SystemUtilities; +import java.util.Timer; +import ghidra.util.SystemUtilities; +import ghidra.util.timer.GTimer; + +/** + * Creates a new {@link GhidraTimer} appropriate for a headed or headless environment. + *

+ * If running a headed environment, the callback will happen on the Swing thread. Otherwise, the + * callback will happen on the non-Swing {@link Timer} thread. + *

+ * See also {@link GTimer} + */ public class GhidraTimerFactory { public static GhidraTimer getGhidraTimer(int initialDelay, int delay, TimerCallback callback) { if (SystemUtilities.isInHeadlessMode()) { return new GhidraSwinglessTimer(initialDelay, delay, callback); - + } return new GhidraSwingTimer(initialDelay, delay, callback); } - - public static void main(String[] args) throws InterruptedException { - System.setProperty(SystemUtilities.HEADLESS_PROPERTY, "true"); - final GhidraTimer t = GhidraTimerFactory.getGhidraTimer(500,500,null); - t.setDelay(500); - TimerCallback callback1 = new TimerCallback() { - int i = 0; - public void timerFired() { - System.out.println("A: "+i); - if (++i == 20) { - t.stop(); - } - } - }; - t.setTimerCallback(callback1); - t.start(); - - final GhidraTimer t2 = GhidraTimerFactory.getGhidraTimer(250, 1000, null); - - TimerCallback callback2 = new TimerCallback() { - int i = 0; - public void timerFired() { - System.out.println("B: "+i); - if (++i == 100) { - t2.stop(); - } - } - }; - t2.setInitialDelay(250); - t2.setTimerCallback(callback2); - t2.start(); - - - - Thread.sleep(20000); - - } } diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/timer/GTimer.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/timer/GTimer.java index bb80759137..7a5b9eccdf 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/util/timer/GTimer.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/timer/GTimer.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. @@ -18,12 +18,17 @@ package ghidra.util.timer; import java.util.Timer; import java.util.TimerTask; +import generic.timer.GhidraTimerFactory; import ghidra.util.Msg; /** * A class to schedule {@link Runnable}s to run after some delay, optionally repeating. This class * uses a {@link Timer} internally to schedule work. Clients of this class are given a monitor * that allows them to check on the state of the runnable, as well as to cancel the runnable. + *

+ * Note: The callback will be called on the {@link Timer}'s thread. + *

+ * See also {@link GhidraTimerFactory} */ public class GTimer { private static Timer timer; diff --git a/Ghidra/Framework/Gui/src/main/java/ghidra/util/bean/GGlassPane.java b/Ghidra/Framework/Gui/src/main/java/ghidra/util/bean/GGlassPane.java index 8b67bf48c1..cb117b955d 100644 --- a/Ghidra/Framework/Gui/src/main/java/ghidra/util/bean/GGlassPane.java +++ b/Ghidra/Framework/Gui/src/main/java/ghidra/util/bean/GGlassPane.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. @@ -69,6 +69,7 @@ public class GGlassPane extends JComponent { * @param painter the painter to add */ public void addPainter(GGlassPanePainter painter) { + painters.remove(painter); painters.add(painter); repaint(); } @@ -99,6 +100,7 @@ public class GGlassPane extends JComponent { /** * Sets the busy state of all glass panes created in the VM. + * @param isBusy the busy state of all glass panes created in the VM. */ public static void setAllGlassPanesBusy(boolean isBusy) { for (GGlassPane glassPane : systemGlassPanes) { @@ -108,6 +110,7 @@ public class GGlassPane extends JComponent { /** * Returns true if this glass pane is blocking user input. + * @return true if this glass pane is blocking user input. */ public boolean isBusy() { return isBusy;