GP-4861 - Created a way to show a message over a given component

This commit is contained in:
dragonmacher 2024-09-04 11:16:52 -04:00
parent 187406f45b
commit 0c365b7afd
11 changed files with 1200 additions and 80 deletions

View File

@ -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);
}
}

View File

@ -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

View File

@ -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);
}

View File

@ -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.
* <P>
* A {@link #setPainter(AnimationPainter) painter} must be supplied before calling {@link #start()}.
* A simple example usage:
* <PRE>
* 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();
* }
*
* </PRE>
* <P>
* 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.
* <P>
* 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.
* <P>
* 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);
}
}
}

View File

@ -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() {

View File

@ -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.
* <P>
* 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);
}
}
}

View File

@ -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<TextShaperLine> 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<TextShaperLine> 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";
}
}
}

View File

@ -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("<html>This is<br>some text that<br>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<TextShaperLine> 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<TextShaperLine> 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<TextShaperLine> 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<TextShaperLine> 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<TextShaperLine> 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<TextShaperLine> lines = shaper.getLines();
assertEquals(1, lines.size());
assertEquals("This is a message", lines.get(0).getText());
assertFalse(shaper.isClipped());
}
}

View File

@ -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.
* <P>
* 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.
* <P>
* 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);
}
}

View File

@ -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.
* <P>
* Note: The callback will be called on the {@link Timer}'s thread.
* <P>
* See also {@link GhidraTimerFactory}
*/
public class GTimer {
private static Timer timer;

View File

@ -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;