mirror of
https://github.com/NationalSecurityAgency/ghidra.git
synced 2025-10-06 03:50:02 +02:00
GT-3318 - Plugin Description display was not correctly using html tags
This commit is contained in:
parent
719841eb20
commit
5007c000dc
4 changed files with 94 additions and 154 deletions
|
@ -15,15 +15,14 @@
|
|||
*/
|
||||
package ghidra.framework.plugintool.dialog;
|
||||
|
||||
import static ghidra.util.HTMLUtilities.*;
|
||||
|
||||
import java.awt.*;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
import javax.swing.*;
|
||||
import javax.swing.text.SimpleAttributeSet;
|
||||
import javax.swing.text.StyleConstants;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import docking.widgets.label.GDHtmlLabel;
|
||||
import ghidra.util.HTMLUtilities;
|
||||
|
||||
|
@ -34,6 +33,7 @@ import ghidra.util.HTMLUtilities;
|
|||
*/
|
||||
public abstract class AbstractDetailsPanel extends JPanel {
|
||||
|
||||
private static final int MIN_WIDTH = 700;
|
||||
protected static final int LEFT_COLUMN_WIDTH = 150;
|
||||
protected static final int RIGHT_MARGIN = 30;
|
||||
|
||||
|
@ -41,7 +41,6 @@ public abstract class AbstractDetailsPanel extends JPanel {
|
|||
protected static SimpleAttributeSet titleAttrSet;
|
||||
|
||||
protected JLabel textLabel;
|
||||
protected Font defaultFont;
|
||||
protected JScrollPane sp;
|
||||
|
||||
/**
|
||||
|
@ -105,15 +104,25 @@ public abstract class AbstractDetailsPanel extends JPanel {
|
|||
*/
|
||||
protected void createMainPanel() {
|
||||
setLayout(new BorderLayout());
|
||||
textLabel = new GDHtmlLabel("");
|
||||
textLabel = new GDHtmlLabel() {
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
|
||||
// overridden to force word-wrapping by limiting the preferred size of the label
|
||||
Dimension mySize = super.getPreferredSize();
|
||||
int rightColumnWidth = AbstractDetailsPanel.this.getWidth() - LEFT_COLUMN_WIDTH;
|
||||
mySize.width = Math.max(MIN_WIDTH, rightColumnWidth);
|
||||
return mySize;
|
||||
}
|
||||
};
|
||||
|
||||
textLabel.setVerticalAlignment(SwingConstants.TOP);
|
||||
textLabel.setOpaque(true);
|
||||
textLabel.setBackground(Color.WHITE);
|
||||
sp = new JScrollPane(textLabel);
|
||||
sp.getVerticalScrollBar().setUnitIncrement(10);
|
||||
sp.setPreferredSize(new Dimension(700, 200));
|
||||
sp.setPreferredSize(new Dimension(MIN_WIDTH, 200));
|
||||
add(sp, BorderLayout.CENTER);
|
||||
defaultFont = new Font("Tahoma", Font.BOLD, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -126,7 +135,7 @@ public abstract class AbstractDetailsPanel extends JPanel {
|
|||
protected void insertRowTitle(StringBuilder buffer, String rowName) {
|
||||
buffer.append("<TR>");
|
||||
buffer.append("<TD VALIGN=\"TOP\">");
|
||||
insertHTMLLine(rowName + ":", titleAttrSet, buffer);
|
||||
insertHTMLLine(buffer, rowName + ":", titleAttrSet);
|
||||
buffer.append("</TD>");
|
||||
}
|
||||
|
||||
|
@ -136,11 +145,12 @@ public abstract class AbstractDetailsPanel extends JPanel {
|
|||
*
|
||||
* @param buffer the string buffer to add to
|
||||
* @param value the text to add
|
||||
* @param attrSet the structure containing formatting information
|
||||
* @param attributes the structure containing formatting information
|
||||
*/
|
||||
protected void insertRowValue(StringBuilder buffer, String value, SimpleAttributeSet attrSet) {
|
||||
buffer.append("<TD VALIGN=\"TOP\">");
|
||||
insertHTMLLine(value, attrSet, buffer);
|
||||
protected void insertRowValue(StringBuilder buffer, String value,
|
||||
SimpleAttributeSet attributes) {
|
||||
buffer.append("<TD VALIGN=\"TOP\" WIDTH=\"80%\">");
|
||||
insertHTMLLine(buffer, value, attributes);
|
||||
buffer.append("</TD>");
|
||||
buffer.append("</TR>");
|
||||
}
|
||||
|
@ -148,127 +158,54 @@ public abstract class AbstractDetailsPanel extends JPanel {
|
|||
/**
|
||||
* Adds text to a string buffer as an html-formatted string, adding formatting information
|
||||
* as specified.
|
||||
*
|
||||
* @param string the string to add
|
||||
* @param attributeSet the formatting instructions
|
||||
* @param buffer the string buffer to add to
|
||||
* @param string the string to add
|
||||
* @param attributes the formatting instructions
|
||||
*/
|
||||
protected void insertHTMLString(String string, SimpleAttributeSet attributeSet,
|
||||
StringBuilder buffer) {
|
||||
protected void insertHTMLString(StringBuilder buffer, String string,
|
||||
SimpleAttributeSet attributes) {
|
||||
|
||||
if (string == null) {
|
||||
return;
|
||||
}
|
||||
buffer.append("<FONT COLOR=\"#");
|
||||
|
||||
Color foregroundColor = (Color) attributeSet.getAttribute(StyleConstants.Foreground);
|
||||
buffer.append(createColorString(foregroundColor));
|
||||
buffer.append("<FONT COLOR=\"");
|
||||
|
||||
Color foregroundColor = (Color) attributes.getAttribute(StyleConstants.Foreground);
|
||||
buffer.append(HTMLUtilities.toHexString(foregroundColor));
|
||||
|
||||
buffer.append("\" FACE=\"");
|
||||
|
||||
buffer.append(attributeSet.getAttribute(StyleConstants.FontFamily).toString());
|
||||
buffer.append(attributes.getAttribute(StyleConstants.FontFamily).toString());
|
||||
|
||||
buffer.append("\">");
|
||||
|
||||
Boolean isBold = (Boolean) attributeSet.getAttribute(StyleConstants.Bold);
|
||||
Boolean isBold = (Boolean) attributes.getAttribute(StyleConstants.Bold);
|
||||
isBold = (isBold == null) ? Boolean.FALSE : isBold;
|
||||
String text = HTMLUtilities.escapeHTML(string);
|
||||
if (isBold) {
|
||||
buffer.append("<B>");
|
||||
text = HTMLUtilities.bold(text);
|
||||
}
|
||||
|
||||
buffer.append(HTMLUtilities.escapeHTML(string));
|
||||
|
||||
if (isBold) {
|
||||
buffer.append("</B>");
|
||||
}
|
||||
buffer.append(text);
|
||||
|
||||
buffer.append("</FONT>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a single line of html into a {@link StringBuffer}, with the given attributes.
|
||||
*
|
||||
* @param string the string to insert
|
||||
* @param attributeSet the attributes to apply
|
||||
* @param buffer the string buffer
|
||||
* @param string the string to insert
|
||||
* @param attributes the attributes to apply
|
||||
*/
|
||||
protected void insertHTMLLine(String string, SimpleAttributeSet attributeSet,
|
||||
StringBuilder buffer) {
|
||||
protected void insertHTMLLine(StringBuilder buffer, String string,
|
||||
SimpleAttributeSet attributes) {
|
||||
if (string == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
insertHTMLString(string, attributeSet, buffer);
|
||||
insertHTMLString(buffer, string, attributes);
|
||||
|
||||
// row padding - newline space
|
||||
buffer.append("<BR>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stringified version of the {@link Color} provided; eg: "8c0000"
|
||||
*
|
||||
* @param color the color to parse
|
||||
* @return string version of the color
|
||||
*/
|
||||
protected String createColorString(Color color) {
|
||||
|
||||
int red = color.getRed();
|
||||
int green = color.getGreen();
|
||||
int blue = color.getBlue();
|
||||
|
||||
return StringUtils.leftPad(Integer.toHexString(red), 2, "0") +
|
||||
StringUtils.leftPad(Integer.toHexString(green), 2, "0") +
|
||||
StringUtils.leftPad(Integer.toHexString(blue), 2, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string with line breaks at the boundary of the window it's being displayed in.
|
||||
* Without this the description would just run on in one long line.
|
||||
*
|
||||
* @param descr the string to format
|
||||
* @return the formatted string
|
||||
*/
|
||||
protected String formatDescription(String descr) {
|
||||
if (descr == null) {
|
||||
return "";
|
||||
}
|
||||
int maxWidth = getMaxStringWidth();
|
||||
int remainingWidth = maxWidth;
|
||||
FontMetrics fm = textLabel.getFontMetrics(defaultFont);
|
||||
int spaceSize = fm.charWidth(' ');
|
||||
StringBuffer sb = new StringBuffer();
|
||||
StringTokenizer st = new StringTokenizer(descr, " ");
|
||||
while (st.hasMoreTokens()) {
|
||||
String str = st.nextToken();
|
||||
if (str.endsWith(".")) {
|
||||
str = str + " ";
|
||||
}
|
||||
int strWidth = fm.stringWidth(str);
|
||||
if (strWidth + spaceSize <= remainingWidth) {
|
||||
sb.append(" ");
|
||||
sb.append(str);
|
||||
remainingWidth -= strWidth + spaceSize;
|
||||
}
|
||||
else {
|
||||
sb.append("<BR>");
|
||||
sb.append(str + " ");
|
||||
remainingWidth = maxWidth - strWidth;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum size that one line of text can be when formatting the description.
|
||||
*
|
||||
* @return the number of characters in the string
|
||||
*/
|
||||
protected int getMaxStringWidth() {
|
||||
|
||||
int width = textLabel.getWidth();
|
||||
if (width == 0) {
|
||||
width = 700;
|
||||
}
|
||||
width -= LEFT_COLUMN_WIDTH + RIGHT_MARGIN; // allow for tabs and right margin
|
||||
return width;
|
||||
buffer.append(BR);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,12 +31,12 @@ import docking.widgets.table.threaded.ThreadedTableModelListener;
|
|||
class ExtensionDetailsPanel extends AbstractDetailsPanel {
|
||||
|
||||
/** Attribute sets define the visual characteristics for each field */
|
||||
private static SimpleAttributeSet nameAttrSet;
|
||||
private static SimpleAttributeSet descrAttrSet;
|
||||
private static SimpleAttributeSet authorAttrSet;
|
||||
private static SimpleAttributeSet createdOnAttrSet;
|
||||
private static SimpleAttributeSet versionAttrSet;
|
||||
private static SimpleAttributeSet pathAttrSet;
|
||||
private SimpleAttributeSet nameAttrSet;
|
||||
private SimpleAttributeSet descrAttrSet;
|
||||
private SimpleAttributeSet authorAttrSet;
|
||||
private SimpleAttributeSet createdOnAttrSet;
|
||||
private SimpleAttributeSet versionAttrSet;
|
||||
private SimpleAttributeSet pathAttrSet;
|
||||
|
||||
ExtensionDetailsPanel(ExtensionTablePanel tablePanel) {
|
||||
super();
|
||||
|
@ -44,7 +44,7 @@ class ExtensionDetailsPanel extends AbstractDetailsPanel {
|
|||
createMainPanel();
|
||||
|
||||
// Any time the table is reloaded or a new selection is made, we want to reload this
|
||||
// panel. This ensures we are alwasy viewing data for the currently-selected item.
|
||||
// panel. This ensures we are always viewing data for the currently-selected item.
|
||||
tablePanel.getTableModel().addThreadedTableModelListener(new ThreadedTableModelListener() {
|
||||
|
||||
@Override
|
||||
|
@ -87,7 +87,7 @@ class ExtensionDetailsPanel extends AbstractDetailsPanel {
|
|||
insertRowValue(buffer, details.getName(), nameAttrSet);
|
||||
|
||||
insertRowTitle(buffer, "Description");
|
||||
insertRowValue(buffer, formatDescription(details.getDescription()), descrAttrSet);
|
||||
insertRowValue(buffer, details.getDescription(), descrAttrSet);
|
||||
|
||||
insertRowTitle(buffer, "Author");
|
||||
insertRowValue(buffer, details.getAuthor(), authorAttrSet);
|
||||
|
|
|
@ -35,15 +35,15 @@ import ghidra.framework.plugintool.util.PluginStatus;
|
|||
*/
|
||||
class PluginDetailsPanel extends AbstractDetailsPanel {
|
||||
|
||||
private static SimpleAttributeSet nameAttrSet;
|
||||
private static SimpleAttributeSet depNameAttrSet;
|
||||
private static SimpleAttributeSet descrAttrSet;
|
||||
private static SimpleAttributeSet categoriesAttrSet;
|
||||
private static SimpleAttributeSet classAttrSet;
|
||||
private static SimpleAttributeSet locAttrSet;
|
||||
private static SimpleAttributeSet developerAttrSet;
|
||||
private static SimpleAttributeSet dependencyAttrSet;
|
||||
private static SimpleAttributeSet noValueAttrSet;
|
||||
private SimpleAttributeSet nameAttrSet;
|
||||
private SimpleAttributeSet depNameAttrSet;
|
||||
private SimpleAttributeSet descrAttrSet;
|
||||
private SimpleAttributeSet categoriesAttrSet;
|
||||
private SimpleAttributeSet classAttrSet;
|
||||
private SimpleAttributeSet locAttrSet;
|
||||
private SimpleAttributeSet developerAttrSet;
|
||||
private SimpleAttributeSet dependencyAttrSet;
|
||||
private SimpleAttributeSet noValueAttrSet;
|
||||
|
||||
private final PluginConfigurationModel model;
|
||||
|
||||
|
@ -54,14 +54,14 @@ class PluginDetailsPanel extends AbstractDetailsPanel {
|
|||
createMainPanel();
|
||||
}
|
||||
|
||||
void setPluginDescription(PluginDescription pluginDescription) {
|
||||
void setPluginDescription(PluginDescription descriptor) {
|
||||
|
||||
textLabel.setText("");
|
||||
if (pluginDescription == null) {
|
||||
if (descriptor == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<PluginDescription> dependencies = model.getDependencies(pluginDescription);
|
||||
List<PluginDescription> dependencies = model.getDependencies(descriptor);
|
||||
Collections.sort(dependencies, (pd1, pd2) -> pd1.getName().compareTo(pd2.getName()));
|
||||
|
||||
StringBuilder buffer = new StringBuilder("<HTML>");
|
||||
|
@ -69,45 +69,44 @@ class PluginDetailsPanel extends AbstractDetailsPanel {
|
|||
buffer.append("<TABLE cellpadding=2>");
|
||||
|
||||
insertRowTitle(buffer, "Name");
|
||||
insertRowValue(buffer, pluginDescription.getName(),
|
||||
insertRowValue(buffer, descriptor.getName(),
|
||||
!dependencies.isEmpty() ? depNameAttrSet : nameAttrSet);
|
||||
|
||||
insertRowTitle(buffer, "Description");
|
||||
insertRowValue(buffer, formatDescription(pluginDescription.getDescription()), descrAttrSet);
|
||||
insertRowValue(buffer, descriptor.getDescription(), descrAttrSet);
|
||||
|
||||
insertRowTitle(buffer, "Status");
|
||||
insertRowValue(buffer, pluginDescription.getStatus().getDescription(),
|
||||
(pluginDescription.getStatus() == PluginStatus.RELEASED) ? titleAttrSet
|
||||
: developerAttrSet);
|
||||
insertRowValue(buffer, descriptor.getStatus().getDescription(),
|
||||
(descriptor.getStatus() == PluginStatus.RELEASED) ? titleAttrSet : developerAttrSet);
|
||||
|
||||
insertRowTitle(buffer, "Package");
|
||||
insertRowValue(buffer, pluginDescription.getPluginPackage().getName(), categoriesAttrSet);
|
||||
insertRowValue(buffer, descriptor.getPluginPackage().getName(), categoriesAttrSet);
|
||||
|
||||
insertRowTitle(buffer, "Category");
|
||||
insertRowValue(buffer, pluginDescription.getCategory(), categoriesAttrSet);
|
||||
insertRowValue(buffer, descriptor.getCategory(), categoriesAttrSet);
|
||||
|
||||
insertRowTitle(buffer, "Plugin Class");
|
||||
insertRowValue(buffer, pluginDescription.getPluginClass().getName(), classAttrSet);
|
||||
insertRowValue(buffer, descriptor.getPluginClass().getName(), classAttrSet);
|
||||
|
||||
insertRowTitle(buffer, "Class Location");
|
||||
insertRowValue(buffer, pluginDescription.getSourceLocation(), locAttrSet);
|
||||
insertRowValue(buffer, descriptor.getSourceLocation(), locAttrSet);
|
||||
|
||||
insertRowTitle(buffer, "Used By");
|
||||
|
||||
buffer.append("<TD VALIGN=\"TOP\">");
|
||||
|
||||
if (dependencies.isEmpty()) {
|
||||
insertHTMLLine("None", titleAttrSet, buffer);
|
||||
insertHTMLLine(buffer, "None", noValueAttrSet);
|
||||
}
|
||||
else {
|
||||
for (int i = 0; i < dependencies.size(); i++) {
|
||||
insertHTMLString(dependencies.get(i).getPluginClass().getName(), dependencyAttrSet,
|
||||
buffer);
|
||||
insertHTMLString(buffer, dependencies.get(i).getPluginClass().getName(),
|
||||
dependencyAttrSet);
|
||||
if (i < dependencies.size() - 1) {
|
||||
insertHTMLString("<BR>", dependencyAttrSet, buffer);
|
||||
insertHTMLString(buffer, "<BR>", dependencyAttrSet);
|
||||
}
|
||||
}
|
||||
insertHTMLLine("", titleAttrSet, buffer); // add a newline
|
||||
insertHTMLLine(buffer, "", titleAttrSet); // add a newline
|
||||
}
|
||||
buffer.append("</TD>");
|
||||
buffer.append("</TR>");
|
||||
|
@ -116,18 +115,18 @@ class PluginDetailsPanel extends AbstractDetailsPanel {
|
|||
|
||||
buffer.append("<TD VALIGN=\"TOP\">");
|
||||
|
||||
List<Class<?>> servicesRequired = pluginDescription.getServicesRequired();
|
||||
List<Class<?>> servicesRequired = descriptor.getServicesRequired();
|
||||
if (servicesRequired.isEmpty()) {
|
||||
insertHTMLLine("None", titleAttrSet, buffer);
|
||||
insertHTMLLine(buffer, "None", noValueAttrSet);
|
||||
}
|
||||
else {
|
||||
for (int i = 0; i < servicesRequired.size(); i++) {
|
||||
insertHTMLString(servicesRequired.get(i).getName(), dependencyAttrSet, buffer);
|
||||
insertHTMLString(buffer, servicesRequired.get(i).getName(), dependencyAttrSet);
|
||||
if (i < dependencies.size() - 1) {
|
||||
insertHTMLString("<BR>", dependencyAttrSet, buffer);
|
||||
insertHTMLString(buffer, "<BR>", dependencyAttrSet);
|
||||
}
|
||||
}
|
||||
insertHTMLLine("", titleAttrSet, buffer); // add a newline
|
||||
insertHTMLLine(buffer, "", titleAttrSet); // add a newline
|
||||
}
|
||||
buffer.append("</TD>");
|
||||
buffer.append("</TR>");
|
||||
|
@ -138,7 +137,7 @@ class PluginDetailsPanel extends AbstractDetailsPanel {
|
|||
//
|
||||
// Optional: Actions loaded by this plugin
|
||||
//
|
||||
addLoadedActionsContent(buffer, pluginDescription);
|
||||
addLoadedActionsContent(buffer, descriptor);
|
||||
|
||||
buffer.append("</TABLE>");
|
||||
|
||||
|
@ -155,13 +154,13 @@ class PluginDetailsPanel extends AbstractDetailsPanel {
|
|||
|
||||
buffer.append("<TR>");
|
||||
buffer.append("<TD VALIGN=\"TOP\">");
|
||||
insertHTMLLine("Loaded Actions:", titleAttrSet, buffer);
|
||||
insertHTMLLine(buffer, "Loaded Actions:", titleAttrSet);
|
||||
buffer.append("</TD>");
|
||||
|
||||
Set<DockingActionIf> actions = model.getActionsForPlugin(pluginDescription);
|
||||
if (actions.size() == 0) {
|
||||
buffer.append("<TD VALIGN=\"TOP\">");
|
||||
insertHTMLLine("No actions for plugin", noValueAttrSet, buffer);
|
||||
insertHTMLLine(buffer, "No actions for plugin", noValueAttrSet);
|
||||
buffer.append("</TD>");
|
||||
buffer.append("</TR>");
|
||||
return;
|
||||
|
@ -174,7 +173,7 @@ class PluginDetailsPanel extends AbstractDetailsPanel {
|
|||
|
||||
for (DockingActionIf dockableAction : actions) {
|
||||
buffer.append("<TR><TD WIDTH=\"200\">");
|
||||
insertHTMLString(dockableAction.getName(), locAttrSet, buffer);
|
||||
insertHTMLString(buffer, dockableAction.getName(), locAttrSet);
|
||||
buffer.append("</TD>");
|
||||
|
||||
buffer.append("<TD WIDTH=\"300\">");
|
||||
|
@ -182,17 +181,17 @@ class PluginDetailsPanel extends AbstractDetailsPanel {
|
|||
String[] menuPath = menuBarData == null ? null : menuBarData.getMenuPath();
|
||||
String menuPathString = createStringForMenuPath(menuPath);
|
||||
if (menuPathString != null) {
|
||||
insertHTMLString(menuPathString, locAttrSet, buffer);
|
||||
insertHTMLString(buffer, menuPathString, locAttrSet);
|
||||
}
|
||||
else {
|
||||
MenuData popupMenuData = dockableAction.getPopupMenuData();
|
||||
String[] popupPath = popupMenuData == null ? null : popupMenuData.getMenuPath();
|
||||
|
||||
if (popupPath != null) {
|
||||
insertHTMLString("(in a context popup menu)", noValueAttrSet, buffer);
|
||||
insertHTMLString(buffer, "(in a context popup menu)", noValueAttrSet);
|
||||
}
|
||||
else {
|
||||
insertHTMLString("Not in a menu", noValueAttrSet, buffer);
|
||||
insertHTMLString(buffer, "Not in a menu", noValueAttrSet);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -202,10 +201,10 @@ class PluginDetailsPanel extends AbstractDetailsPanel {
|
|||
KeyStroke keyBinding = dockableAction.getKeyBinding();
|
||||
if (keyBinding != null) {
|
||||
String keyStrokeString = KeyBindingUtils.parseKeyStroke(keyBinding);
|
||||
insertHTMLString(keyStrokeString, locAttrSet, buffer);
|
||||
insertHTMLString(buffer, keyStrokeString, locAttrSet);
|
||||
}
|
||||
else {
|
||||
insertHTMLString("No keybinding", noValueAttrSet, buffer);
|
||||
insertHTMLString(buffer, "No keybinding", noValueAttrSet);
|
||||
}
|
||||
|
||||
buffer.append("</TD></TR>");
|
||||
|
|
|
@ -182,8 +182,12 @@ public class PluginInstallerDialog extends DialogComponentProvider {
|
|||
help.registerHelp(table, new HelpLocation(GenericHelpTopics.TOOL, "PluginDialog"));
|
||||
|
||||
table.getSelectionModel().addListSelectionListener(e -> {
|
||||
int row = table.getSelectedRow();
|
||||
|
||||
if (e.getValueIsAdjusting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int row = table.getSelectedRow();
|
||||
if (row < 0 || row > pluginDescriptions.size()) {
|
||||
pluginDetailsPanel.setPluginDescription(null);
|
||||
return;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue