| | 1 | // License: GPL. For details, see LICENSE file. |
| | 2 | package org.openstreetmap.josm.gui.widgets; |
| | 3 | |
| | 4 | import java.awt.Color; |
| | 5 | import java.awt.Component; |
| | 6 | import java.awt.Cursor; |
| | 7 | import java.awt.Point; |
| | 8 | import java.awt.Rectangle; |
| | 9 | import java.awt.event.MouseEvent; |
| | 10 | import java.text.MessageFormat; |
| | 11 | import java.util.Arrays; |
| | 12 | |
| | 13 | import javax.annotation.Nullable; |
| | 14 | import javax.swing.JLabel; |
| | 15 | import javax.swing.JTree; |
| | 16 | import javax.swing.event.HyperlinkEvent; |
| | 17 | import javax.swing.event.MouseInputListener; |
| | 18 | import javax.swing.text.html.HTMLDocument; |
| | 19 | import javax.swing.text.html.StyleSheet; |
| | 20 | import javax.swing.tree.DefaultMutableTreeNode; |
| | 21 | import javax.swing.tree.DefaultTreeCellRenderer; |
| | 22 | import javax.swing.tree.TreePath; |
| | 23 | |
| | 24 | import org.openstreetmap.josm.tools.ColorHelper; |
| | 25 | import org.openstreetmap.josm.tools.OpenBrowser; |
| | 26 | |
| | 27 | /** |
| | 28 | * A {@link javax.swing.tree.TreeCellRenderer} for cells that may contain hyperlinks. |
| | 29 | * @since xxx |
| | 30 | */ |
| | 31 | public class HtmlTreeCellRenderer extends DefaultTreeCellRenderer { |
| | 32 | /** |
| | 33 | * This only exists since the JTree cannot pass events to subcomponents. |
| | 34 | */ |
| | 35 | private static class JTreeMouseInputListener implements MouseInputListener { |
| | 36 | @Override |
| | 37 | public void mouseClicked(MouseEvent mouseEvent) { |
| | 38 | dispatchEvent(mouseEvent); |
| | 39 | } |
| | 40 | |
| | 41 | @Override |
| | 42 | public void mouseMoved(MouseEvent mouseEvent) { |
| | 43 | dispatchEvent(mouseEvent); |
| | 44 | } |
| | 45 | |
| | 46 | @Override |
| | 47 | public void mousePressed(MouseEvent mouseEvent) { |
| | 48 | // Do nothing -- this can cause issues with the popup menu |
| | 49 | } |
| | 50 | |
| | 51 | @Override |
| | 52 | public void mouseReleased(MouseEvent mouseEvent) { |
| | 53 | // Do nothing -- this can cause issues with the popup menu |
| | 54 | } |
| | 55 | |
| | 56 | @Override |
| | 57 | public void mouseEntered(MouseEvent mouseEvent) { |
| | 58 | dispatchEvent(mouseEvent); |
| | 59 | } |
| | 60 | |
| | 61 | @Override |
| | 62 | public void mouseExited(MouseEvent mouseEvent) { |
| | 63 | dispatchEvent(mouseEvent); |
| | 64 | } |
| | 65 | |
| | 66 | @Override |
| | 67 | public void mouseDragged(MouseEvent mouseEvent) { |
| | 68 | dispatchEvent(mouseEvent); |
| | 69 | } |
| | 70 | |
| | 71 | /** |
| | 72 | * Dispatch mouse events for HTML-like cells |
| | 73 | * @param mouseEvent The event to dispatch |
| | 74 | */ |
| | 75 | private static void dispatchEvent(MouseEvent mouseEvent) { |
| | 76 | if (mouseEvent.getComponent() instanceof JTree) { |
| | 77 | final Point p = mouseEvent.getPoint(); |
| | 78 | final JTree tree = (JTree) mouseEvent.getComponent(); |
| | 79 | final Component component = getComponentAt(tree, p); // p is translated here |
| | 80 | if (component != null) { |
| | 81 | component.dispatchEvent(new MouseEvent(component, // Use the component we found to fire the event |
| | 82 | mouseEvent.getID(), mouseEvent.getWhen(), mouseEvent.getModifiers(), |
| | 83 | p.x, p.y, mouseEvent.getClickCount(), mouseEvent.isPopupTrigger(), |
| | 84 | mouseEvent.getButton())); |
| | 85 | component.setBounds(0, 0, 0, 0); |
| | 86 | } |
| | 87 | } |
| | 88 | } |
| | 89 | |
| | 90 | /** |
| | 91 | * Get a rendered component for HTML |
| | 92 | * @param tree The tree to get the component from |
| | 93 | * @param p The point to get the component at (will be modified) |
| | 94 | * @return The component |
| | 95 | */ |
| | 96 | @Nullable |
| | 97 | private static Component getComponentAt(JTree tree, Point p) { |
| | 98 | final TreePath path = tree.getPathForLocation(p.x, p.y); |
| | 99 | if (path != null && tree.getCellRenderer() instanceof HtmlTreeCellRenderer) { |
| | 100 | final HtmlTreeCellRenderer renderer = (HtmlTreeCellRenderer) tree.getCellRenderer(); |
| | 101 | final JosmEditorPane panel = renderer.panel; |
| | 102 | final int row = tree.getRowForPath(path); |
| | 103 | final Object last = path.getLastPathComponent(); |
| | 104 | // We need to get the right text into the panel, so call the renderer |
| | 105 | final Component component = renderer.getTreeCellRendererComponent(tree, last, |
| | 106 | tree.isRowSelected(row), tree.isExpanded(row), |
| | 107 | tree.getModel().isLeaf(last), row, true); |
| | 108 | final Rectangle bounds = tree.getPathBounds(path); |
| | 109 | if (bounds != null) { |
| | 110 | // If we don't translate the point, we are attempting to get the link in the wrong frame of reference |
| | 111 | // The reference x/y are 0. This is set in BasicTextUI#getVisibleEditorRect, so we must translate the point here. |
| | 112 | final int width = (component instanceof JLabel) |
| | 113 | ? ((JLabel) component).getIcon().getIconWidth() + ((JLabel) component).getIconTextGap() |
| | 114 | : 0; |
| | 115 | // This translation puts the origin point at the upper-left of the JLabel, but moves it to the right of the icon to the text |
| | 116 | // Yes, it is - width, not + width. If you change this, make certain that clicking links still works! |
| | 117 | p.translate(-bounds.x - width, -bounds.y); |
| | 118 | // Just make certain that we are consistent for other objects. |
| | 119 | bounds.x = 0; |
| | 120 | bounds.y = 0; |
| | 121 | // Set the correct width |
| | 122 | bounds.width -= width; |
| | 123 | // Set the bounds from the JTree component (needed for proper layout) |
| | 124 | panel.setBounds(bounds); |
| | 125 | return panel; |
| | 126 | } |
| | 127 | } |
| | 128 | return null; |
| | 129 | } |
| | 130 | } |
| | 131 | |
| | 132 | private static final long serialVersionUID = -4842755204541968238L; |
| | 133 | /** |
| | 134 | * A reusable panel to avoid new objects where possible. |
| | 135 | * It isn't worth it to make a new object for each row, since it doesn't receive mouse events (tested on Java 8). |
| | 136 | */ |
| | 137 | private final JosmEditorPane panel = new JosmEditorPane(); |
| | 138 | /** JTree does not send mouse events to subcomponents */ |
| | 139 | private final JTreeMouseInputListener mouseTreeListener = new JTreeMouseInputListener(); |
| | 140 | /** The tree used to set cursors when entering/leaving a hyperlink */ |
| | 141 | private JTree tree; |
| | 142 | |
| | 143 | /** |
| | 144 | * Create a new renderer |
| | 145 | */ |
| | 146 | public HtmlTreeCellRenderer() { |
| | 147 | panel.addHyperlinkListener(e -> { |
| | 148 | if (e.getURL() != null) { |
| | 149 | if (HyperlinkEvent.EventType.ACTIVATED.equals(e.getEventType())) { |
| | 150 | OpenBrowser.displayUrl(e.getURL().toString()); |
| | 151 | } else if (this.tree != null && HyperlinkEvent.EventType.ENTERED.equals(e.getEventType())) { |
| | 152 | this.tree.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); |
| | 153 | } else if (this.tree != null && HyperlinkEvent.EventType.EXITED.equals(e.getEventType())) { |
| | 154 | this.tree.setCursor(Cursor.getDefaultCursor()); |
| | 155 | } |
| | 156 | } |
| | 157 | }); |
| | 158 | JosmEditorPane.makeJLabelLike(panel, false); |
| | 159 | final Color defaultLink = ColorHelper.html2color(JosmEditorPane.getLinkColor()); |
| | 160 | final Color lighterLink = defaultLink.brighter().brighter(); |
| | 161 | final Color darkerLink = defaultLink.darker().darker(); |
| | 162 | final String divRule = "{0} '{'background-color:{1}; color:{2};'}'"; |
| | 163 | final String aRule = "{0} a '{'text-decoration: underline; color:{1}'}'"; |
| | 164 | StyleSheet ss = ((HTMLDocument) panel.getDocument()).getStyleSheet(); |
| | 165 | ss.addRule(MessageFormat.format(divRule, ".selected", ColorHelper.color2html(getBackgroundSelectionColor()), |
| | 166 | ColorHelper.color2html(getTextSelectionColor()))); |
| | 167 | ss.addRule(MessageFormat.format(aRule, ".selected", |
| | 168 | ColorHelper.color2html(getAppropriateColor(getBackgroundSelectionColor(), defaultLink, lighterLink, darkerLink)))); |
| | 169 | ss.addRule(MessageFormat.format(divRule, ".not-selected", ColorHelper.color2html(getBackgroundNonSelectionColor()), |
| | 170 | ColorHelper.color2html(getTextNonSelectionColor()))); |
| | 171 | ss.addRule(MessageFormat.format(aRule, ".not-selected", |
| | 172 | ColorHelper.color2html(getAppropriateColor(getBackgroundNonSelectionColor(), defaultLink, lighterLink, darkerLink)))); |
| | 173 | } |
| | 174 | |
| | 175 | @Override |
| | 176 | public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded, |
| | 177 | boolean leaf, int row, boolean hasFocus) { |
| | 178 | final Component component = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus); |
| | 179 | this.tree = tree; |
| | 180 | if (value instanceof DefaultMutableTreeNode) { |
| | 181 | if (Arrays.stream(tree.getMouseListeners()).noneMatch(this.mouseTreeListener::equals)) { |
| | 182 | tree.addMouseListener(this.mouseTreeListener); |
| | 183 | tree.addMouseMotionListener(this.mouseTreeListener); |
| | 184 | } |
| | 185 | final DefaultMutableTreeNode node = (DefaultMutableTreeNode) value; |
| | 186 | final Object object = node.getUserObject(); |
| | 187 | if (object instanceof String && ((String) object).startsWith("<html>") && ((String) object).endsWith("</html>")) { |
| | 188 | final String text = (String) object; |
| | 189 | final String modified = "<html><div class=\"" + (sel ? "selected" : "not-selected") + "\">" |
| | 190 | + text.substring("<html>".length(), text.length() - "</html>".length()) + "</div></html>"; |
| | 191 | this.panel.setText(modified); |
| | 192 | this.setText(this.panel.getText()); // This gets the custom CSS into the JLabel |
| | 193 | } |
| | 194 | } |
| | 195 | return component; |
| | 196 | } |
| | 197 | |
| | 198 | /** |
| | 199 | * Convert the rgb value for use in a relative luminance calculation |
| | 200 | * @param rgb The value to convert |
| | 201 | * @return The appropriate value to use in the calculation |
| | 202 | */ |
| | 203 | private static double convertRGB(int rgb) { |
| | 204 | final double normalized = rgb / 255d; |
| | 205 | if (normalized <= 0.03928) { |
| | 206 | return normalized / 12.92; |
| | 207 | } |
| | 208 | return Math.pow(((normalized + 0.055)/1.055), 2.4); |
| | 209 | } |
| | 210 | |
| | 211 | /** |
| | 212 | * Calculate the contrast ratio between two luminance values |
| | 213 | * @param luminanceOne The first luminance value |
| | 214 | * @param luminanceTwo The second luminance value |
| | 215 | * @return The contrast ratio. Higher is usually better. |
| | 216 | */ |
| | 217 | private static double contrastRatio(double luminanceOne, double luminanceTwo) { |
| | 218 | final double min = Math.min(luminanceOne, luminanceTwo); |
| | 219 | final double max = Math.max(luminanceOne, luminanceTwo); |
| | 220 | return (max + 0.05) / (min + 0.05); |
| | 221 | } |
| | 222 | |
| | 223 | /** |
| | 224 | * Calculate the luminance of a color |
| | 225 | * @param red The red part |
| | 226 | * @param green The green part |
| | 227 | * @param blue The blue part |
| | 228 | * @return The relative luminance (0-1, 0 black, 1 white) |
| | 229 | */ |
| | 230 | private static double luminance(int red, int green, int blue) { |
| | 231 | return 0.2126 * convertRGB(red) |
| | 232 | + 0.7152 * convertRGB(green) |
| | 233 | + 0.0722 * convertRGB(blue); |
| | 234 | } |
| | 235 | |
| | 236 | /** |
| | 237 | * Get the appropriate color given a background |
| | 238 | * @param background The background color |
| | 239 | * @param options The options to choose from |
| | 240 | * @return The appropriate color |
| | 241 | */ |
| | 242 | private static Color getAppropriateColor(Color background, Color... options) { |
| | 243 | if (options.length < 2) { |
| | 244 | throw new IllegalArgumentException("There must be at least two color options"); |
| | 245 | } |
| | 246 | // sRGB calculation |
| | 247 | final double backgroundLuminance = luminance(background.getRed(), background.getGreen(), background.getBlue()); |
| | 248 | double lastContrastRatio = 0; // W3 recommends a contrast ratio of at least 4.5:1. We should be shooting for 7:1. |
| | 249 | Color lastColor = null; |
| | 250 | for (Color option : options) { |
| | 251 | final double contrastRatio = contrastRatio(backgroundLuminance, luminance(option.getRed(), option.getGreen(), option.getBlue())); |
| | 252 | if (contrastRatio > lastContrastRatio) { |
| | 253 | lastContrastRatio = contrastRatio; |
| | 254 | lastColor = option; |
| | 255 | } |
| | 256 | } |
| | 257 | return lastColor; |
| | 258 | } |
| | 259 | } |