Ticket #11153: HtmlTreeCellRenderer.java

File HtmlTreeCellRenderer.java, 11.7 KB (added by taylor.smock, 3 years ago)

src/org/openstreetmap/josm/gui/widgets/HtmlTreeCellRenderer.java from attachment:11153.3.patch

Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.widgets;
3
4import java.awt.Color;
5import java.awt.Component;
6import java.awt.Cursor;
7import java.awt.Point;
8import java.awt.Rectangle;
9import java.awt.event.MouseEvent;
10import java.text.MessageFormat;
11import java.util.Arrays;
12
13import javax.annotation.Nullable;
14import javax.swing.JLabel;
15import javax.swing.JTree;
16import javax.swing.event.HyperlinkEvent;
17import javax.swing.event.MouseInputListener;
18import javax.swing.text.html.HTMLDocument;
19import javax.swing.text.html.StyleSheet;
20import javax.swing.tree.DefaultMutableTreeNode;
21import javax.swing.tree.DefaultTreeCellRenderer;
22import javax.swing.tree.TreePath;
23
24import org.openstreetmap.josm.tools.ColorHelper;
25import org.openstreetmap.josm.tools.OpenBrowser;
26
27/**
28 * A {@link javax.swing.tree.TreeCellRenderer} for cells that may contain hyperlinks.
29 * @since xxx
30 */
31public 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}