| | 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.Point; |
| | 7 | import java.awt.Rectangle; |
| | 8 | import java.awt.event.MouseEvent; |
| | 9 | import java.text.MessageFormat; |
| | 10 | import java.util.Arrays; |
| | 11 | |
| | 12 | import javax.annotation.Nullable; |
| | 13 | import javax.swing.JTree; |
| | 14 | import javax.swing.event.HyperlinkEvent; |
| | 15 | import javax.swing.event.MouseInputAdapter; |
| | 16 | import javax.swing.text.html.HTMLDocument; |
| | 17 | import javax.swing.text.html.StyleSheet; |
| | 18 | import javax.swing.tree.DefaultMutableTreeNode; |
| | 19 | import javax.swing.tree.DefaultTreeCellRenderer; |
| | 20 | import javax.swing.tree.TreePath; |
| | 21 | |
| | 22 | import org.openstreetmap.josm.tools.ColorHelper; |
| | 23 | import org.openstreetmap.josm.tools.OpenBrowser; |
| | 24 | |
| | 25 | /** |
| | 26 | * A {@link javax.swing.tree.TreeCellRenderer} for cells that may contain hyperlinks. |
| | 27 | * @since xxx |
| | 28 | */ |
| | 29 | public class HtmlTreeCellRenderer extends DefaultTreeCellRenderer { |
| | 30 | /** |
| | 31 | * This only exists since the JTree cannot pass events to subcomponents. |
| | 32 | */ |
| | 33 | private static class JTreeMouseInputListener extends MouseInputAdapter { |
| | 34 | @Override |
| | 35 | public void mouseClicked(MouseEvent mouseEvent) { |
| | 36 | if (mouseEvent.getComponent() instanceof JTree) { |
| | 37 | final Point p = mouseEvent.getPoint(); |
| | 38 | final JTree tree = (JTree) mouseEvent.getComponent(); |
| | 39 | final Component component = getComponentAt(tree, p); // p is translated here |
| | 40 | if (component != null) { |
| | 41 | component.dispatchEvent(new MouseEvent(component, // Use the component we found to fire the event |
| | 42 | mouseEvent.getID(), mouseEvent.getWhen(), mouseEvent.getModifiers(), |
| | 43 | p.x, p.y, mouseEvent.getClickCount(), mouseEvent.isPopupTrigger(), |
| | 44 | mouseEvent.getButton())); |
| | 45 | } |
| | 46 | } |
| | 47 | } |
| | 48 | |
| | 49 | /** |
| | 50 | * Get a component at |
| | 51 | * @param tree The tree to get the component from |
| | 52 | * @param p The point to get the component at (will be modified) |
| | 53 | * @return The component |
| | 54 | */ |
| | 55 | @Nullable |
| | 56 | private static Component getComponentAt(JTree tree, Point p) { |
| | 57 | final TreePath path = tree.getPathForLocation(p.x, p.y); |
| | 58 | if (path != null) { |
| | 59 | final int row = tree.getRowForPath(path); |
| | 60 | final Object last = path.getLastPathComponent(); |
| | 61 | // We need to get the component as "shown" so that we can get the link clicked. |
| | 62 | final Component component = tree.getCellRenderer().getTreeCellRendererComponent(tree, last, |
| | 63 | tree.isRowSelected(row), tree.isExpanded(row), |
| | 64 | tree.getModel().isLeaf(last), row, true); |
| | 65 | final Rectangle bounds = tree.getPathBounds(path); |
| | 66 | if (bounds != null) { |
| | 67 | // If we don't translate the point, we are attempting to get the link in the wrong frame of reference |
| | 68 | // The reference x/y are 0. This is set in BasicTextUI#getVisibleEditorRect, so we must translate the point here. |
| | 69 | p.translate(-bounds.x, -bounds.y); |
| | 70 | // Just make certain that we are consistent for other objects. |
| | 71 | bounds.x = 0; |
| | 72 | bounds.y = 0; |
| | 73 | // Set the bounds from the JTree component (needed for proper layout) |
| | 74 | component.setBounds(bounds); |
| | 75 | return component; |
| | 76 | } |
| | 77 | } |
| | 78 | return null; |
| | 79 | } |
| | 80 | } |
| | 81 | |
| | 82 | private static final long serialVersionUID = -4842755204541968238L; |
| | 83 | /** |
| | 84 | * A reusable panel to avoid new objects where possible. |
| | 85 | * It isn't worth it to make a new object for each row, since it doesn't receive mouse events (tested on Java 8). |
| | 86 | */ |
| | 87 | private final JosmEditorPane panel = new JosmEditorPane(); |
| | 88 | /** JTree does not send mouse events to subcomponents */ |
| | 89 | private final JTreeMouseInputListener mouseTreeListener = new JTreeMouseInputListener(); |
| | 90 | |
| | 91 | /** |
| | 92 | * Create a new renderer |
| | 93 | */ |
| | 94 | public HtmlTreeCellRenderer() { |
| | 95 | panel.addHyperlinkListener(e -> { |
| | 96 | if (e.getURL() != null && HyperlinkEvent.EventType.ACTIVATED.equals(e.getEventType())) { |
| | 97 | OpenBrowser.displayUrl(e.getURL().toString()); |
| | 98 | } |
| | 99 | }); |
| | 100 | JosmEditorPane.makeJLabelLike(panel, false); |
| | 101 | final Color defaultLink = ColorHelper.html2color(JosmEditorPane.getLinkColor()); |
| | 102 | final Color lighterLink = defaultLink.brighter().brighter(); |
| | 103 | final Color darkerLink = defaultLink.darker().darker(); |
| | 104 | final String divRule = "{0} '{'background-color:{1}; color:{2};'}'"; |
| | 105 | final String aRule = "{0} a '{'text-decoration: underline; color:{1}'}'"; |
| | 106 | StyleSheet ss = ((HTMLDocument) panel.getDocument()).getStyleSheet(); |
| | 107 | ss.addRule(MessageFormat.format(divRule, ".selected", ColorHelper.color2html(getBackgroundSelectionColor()), |
| | 108 | ColorHelper.color2html(getTextSelectionColor()))); |
| | 109 | ss.addRule(MessageFormat.format(aRule, ".selected", |
| | 110 | ColorHelper.color2html(getAppropriateColor(getBackgroundSelectionColor(), defaultLink, lighterLink, darkerLink)))); |
| | 111 | ss.addRule(MessageFormat.format(divRule, ".not-selected", ColorHelper.color2html(getBackgroundNonSelectionColor()), |
| | 112 | ColorHelper.color2html(getTextNonSelectionColor()))); |
| | 113 | ss.addRule(MessageFormat.format(aRule, ".not-selected", |
| | 114 | ColorHelper.color2html(getAppropriateColor(getBackgroundNonSelectionColor(), defaultLink, lighterLink, darkerLink)))); |
| | 115 | } |
| | 116 | |
| | 117 | @Override |
| | 118 | public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded, |
| | 119 | boolean leaf, int row, boolean hasFocus) { |
| | 120 | if (value instanceof DefaultMutableTreeNode) { |
| | 121 | if (Arrays.stream(tree.getMouseListeners()).noneMatch(this.mouseTreeListener::equals)) { |
| | 122 | tree.addMouseListener(this.mouseTreeListener); |
| | 123 | tree.addMouseMotionListener(this.mouseTreeListener); |
| | 124 | } |
| | 125 | final DefaultMutableTreeNode node = (DefaultMutableTreeNode) value; |
| | 126 | final Object object = node.getUserObject(); |
| | 127 | if (object instanceof String && ((String) object).startsWith("<html>") && ((String) object).endsWith("</html>")) { |
| | 128 | final String text = (String) object; |
| | 129 | final String modified = "<html><div class=\"" + (sel ? "selected" : "not-selected") + "\">" |
| | 130 | + text.substring("<html>".length(), text.length() - "</html>".length()) + "</div></html>"; |
| | 131 | this.panel.setText(modified); |
| | 132 | return this.panel; |
| | 133 | } |
| | 134 | } |
| | 135 | return super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus); |
| | 136 | } |
| | 137 | |
| | 138 | /** |
| | 139 | * Convert the rgb value for use in a relative luminance calculation |
| | 140 | * @param rgb The value to convert |
| | 141 | * @return The appropriate value to use in the calculation |
| | 142 | */ |
| | 143 | private static double convertRGB(int rgb) { |
| | 144 | final double normalized = rgb / 255d; |
| | 145 | if (normalized <= 0.03928) { |
| | 146 | return normalized / 12.92; |
| | 147 | } |
| | 148 | return Math.pow(((normalized + 0.055)/1.055), 2.4); |
| | 149 | } |
| | 150 | |
| | 151 | /** |
| | 152 | * Calculate the contrast ratio between two luminance values |
| | 153 | * @param luminanceOne The first luminance value |
| | 154 | * @param luminanceTwo The second luminance value |
| | 155 | * @return The contrast ratio. Higher is usually better. |
| | 156 | */ |
| | 157 | private static double contrastRatio(double luminanceOne, double luminanceTwo) { |
| | 158 | final double min = Math.min(luminanceOne, luminanceTwo); |
| | 159 | final double max = Math.max(luminanceOne, luminanceTwo); |
| | 160 | return (max + 0.05) / (min + 0.05); |
| | 161 | } |
| | 162 | |
| | 163 | /** |
| | 164 | * Calculate the luminance of a color |
| | 165 | * @param red The red part |
| | 166 | * @param green The green part |
| | 167 | * @param blue The blue part |
| | 168 | * @return The relative luminance (0-1, 0 black, 1 white) |
| | 169 | */ |
| | 170 | private static double luminance(int red, int green, int blue) { |
| | 171 | return 0.2126 * convertRGB(red) |
| | 172 | + 0.7152 * convertRGB(green) |
| | 173 | + 0.0722 * convertRGB(blue); |
| | 174 | } |
| | 175 | |
| | 176 | /** |
| | 177 | * Get the appropriate color given a background |
| | 178 | * @param background The background color |
| | 179 | * @param options The options to choose from |
| | 180 | * @return The appropriate color |
| | 181 | */ |
| | 182 | private static Color getAppropriateColor(Color background, Color... options) { |
| | 183 | if (options.length < 2) { |
| | 184 | throw new IllegalArgumentException("There must be at least two color options"); |
| | 185 | } |
| | 186 | // sRGB calculation |
| | 187 | final double backgroundLuminance = luminance(background.getRed(), background.getGreen(), background.getBlue()); |
| | 188 | double lastContrastRatio = 0; // W3 recommends a contrast ratio of at least 4.5:1. We should be shooting for 7:1. |
| | 189 | Color lastColor = null; |
| | 190 | for (Color option : options) { |
| | 191 | final double contrastRatio = contrastRatio(backgroundLuminance, luminance(option.getRed(), option.getGreen(), option.getBlue())); |
| | 192 | if (contrastRatio > lastContrastRatio) { |
| | 193 | lastContrastRatio = contrastRatio; |
| | 194 | lastColor = option; |
| | 195 | } |
| | 196 | } |
| | 197 | return lastColor; |
| | 198 | } |
| | 199 | } |