Commit | Line | Data |
---|---|---|
d831b327 NR |
1 | package be.nikiroo.utils.ui; |
2 | ||
3 | import java.awt.BorderLayout; | |
4 | import java.awt.Color; | |
5 | import java.awt.Dimension; | |
6 | import java.awt.Graphics; | |
7 | import java.awt.Graphics2D; | |
8 | import java.awt.Image; | |
9 | import java.awt.Rectangle; | |
10 | import java.awt.image.BufferedImage; | |
11 | import java.util.HashMap; | |
12 | import java.util.Map; | |
13 | ||
14 | import javax.swing.BorderFactory; | |
15 | import javax.swing.JComponent; | |
16 | import javax.swing.JLabel; | |
17 | import javax.swing.JPanel; | |
18 | import javax.swing.SwingConstants; | |
19 | ||
20 | /** | |
21 | * A graphical item that can be presented in a list and supports user | |
22 | * interaction. | |
23 | * <p> | |
24 | * Can be selected, hovered... | |
25 | * | |
26 | * @author niki | |
27 | */ | |
28 | abstract public class Item extends JPanel { | |
29 | static private final long serialVersionUID = 1L; | |
30 | ||
31 | static private Map<Dimension, BufferedImage> empty = new HashMap<Dimension, BufferedImage>(); | |
32 | static private Map<Dimension, BufferedImage> error = new HashMap<Dimension, BufferedImage>(); | |
33 | static private Map<Color, JComponent> statuses = new HashMap<Color, JComponent>(); | |
34 | ||
35 | private String id; | |
36 | private boolean selected; | |
37 | private boolean hovered; | |
38 | ||
39 | private String mainTemplate; | |
40 | private String secondaryTemplate; | |
41 | ||
42 | private boolean hasImage; | |
43 | private JLabel title; | |
44 | private JLabel secondary; | |
45 | private JLabel statusIndicatorOn; | |
46 | private JLabel statusIndicatorOff; | |
47 | private JLabel statusIndicatorUnknown; | |
48 | private Image image; | |
49 | private boolean imageError; | |
50 | ||
51 | private String cachedMain; | |
52 | private String cachedOptSecondary; | |
53 | private Integer cachedStatus; | |
54 | ||
55 | /** | |
56 | * Create a new {@link Item} | |
57 | * | |
58 | * @param id | |
59 | * an ID that represents this {@link Item} (can be NULL) | |
60 | * @param hasImage | |
61 | * this {@link Item} will contain an image | |
62 | */ | |
63 | public Item(String id, boolean hasImage) { | |
64 | this.id = id; | |
65 | this.hasImage = hasImage; | |
66 | init(hasImage); | |
67 | } | |
68 | ||
69 | // Configuration : | |
70 | ||
71 | protected int getMaxDisplaySize() { | |
72 | return 40; | |
73 | } | |
74 | ||
75 | protected int getCoverWidth() { | |
76 | return 100; | |
77 | } | |
78 | ||
79 | protected int getCoverHeight() { | |
80 | return 150; | |
81 | } | |
82 | ||
83 | protected int getTextWidth() { | |
84 | return getCoverWidth() + 40; | |
85 | } | |
86 | ||
87 | protected int getTextHeight() { | |
88 | return 50; | |
89 | } | |
90 | ||
91 | protected int getCoverVOffset() { | |
92 | return 20; | |
93 | } | |
94 | ||
95 | protected int getCoverHOffset() { | |
96 | return 0; | |
97 | } | |
98 | ||
99 | protected int getHGap() { | |
100 | return 10; | |
101 | } | |
102 | ||
103 | /** Colour used for the secondary item (author/word count). */ | |
104 | protected Color getSecondaryColor() { | |
105 | return new Color(128, 128, 128); | |
106 | } | |
107 | ||
108 | /** | |
109 | * Return a display-ready version of the main information to show. | |
110 | * <p> | |
111 | * Note that you can make use of {@link Item#limit(String)}. | |
112 | * | |
113 | * @return the main info in a ready-to-display version, cannot be NULL | |
114 | */ | |
115 | abstract protected String getMainInfoDisplay(); | |
116 | ||
117 | /** | |
118 | * Return a display-ready version of the secondary information to show. | |
119 | * <p> | |
120 | * Note that you can make use of {@link Item#limit(String)}. | |
121 | * | |
122 | * @return the main info in a ready-to-display version, cannot be NULL | |
123 | */ | |
124 | abstract protected String getSecondaryInfoDisplay(); | |
125 | ||
126 | /** | |
127 | * The current status for the status indicator. | |
128 | * <p> | |
129 | * Note that NULL and negative values will create "hollow" indicators, while | |
130 | * other values will create "filled" indicators. | |
131 | * | |
132 | * @return the status which can be NULL, presumably for "Unknown" | |
133 | */ | |
134 | abstract protected Integer getStatus(); | |
135 | ||
136 | /** | |
137 | * Get the background colour to use according to the given state. | |
138 | * <p> | |
139 | * Since it is an overlay, an opaque colour will of course mask everything. | |
140 | * | |
141 | * @param enabled | |
142 | * the item is enabled | |
143 | * @param selected | |
144 | * the item is selected | |
145 | * @param hovered | |
146 | * the mouse cursor currently hovers over the item | |
147 | * | |
148 | * @return the correct background colour to use | |
149 | */ | |
150 | abstract protected Color getOverlayColor(boolean enabled, boolean selected, | |
151 | boolean hovered); | |
152 | ||
153 | /** | |
154 | * Get the colour to use for the status indicator. | |
155 | * <p> | |
156 | * Return NULL if you don't want a status indicator for this state. | |
157 | * | |
158 | * @param status | |
159 | * the current status as returned by {@link Item#getStatus()} | |
160 | * | |
161 | * @return the base colour to use, or NULL for no status indicator | |
162 | */ | |
163 | abstract protected Color getStatusIndicatorColor(Integer status); | |
164 | ||
165 | /** | |
166 | * Initialise this {@link Item}. | |
167 | */ | |
168 | private void init(boolean hasImage) { | |
169 | if (!hasImage) { | |
170 | title = new JLabel(); | |
171 | mainTemplate = "${MAIN}"; | |
172 | secondary = new JLabel(); | |
173 | secondaryTemplate = "${SECONDARY}"; | |
174 | secondary.setForeground(getSecondaryColor()); | |
175 | ||
176 | JPanel idTitle = null; | |
177 | if (id != null && !id.isEmpty()) { | |
178 | JLabel idLabel = new JLabel(id); | |
179 | idLabel.setPreferredSize(new JLabel(" 999 ").getPreferredSize()); | |
180 | idLabel.setForeground(Color.gray); | |
181 | idLabel.setHorizontalAlignment(SwingConstants.CENTER); | |
182 | ||
183 | idTitle = new JPanel(new BorderLayout()); | |
184 | idTitle.setOpaque(false); | |
185 | idTitle.add(idLabel, BorderLayout.WEST); | |
186 | idTitle.add(title, BorderLayout.CENTER); | |
187 | } | |
188 | ||
189 | setLayout(new BorderLayout()); | |
190 | if (idTitle != null) | |
191 | add(idTitle, BorderLayout.CENTER); | |
192 | add(secondary, BorderLayout.EAST); | |
193 | } else { | |
194 | image = null; | |
195 | title = new JLabel(); | |
196 | secondary = new JLabel(); | |
197 | secondaryTemplate = ""; | |
198 | ||
199 | String color = String.format("#%X%X%X", getSecondaryColor() | |
200 | .getRed(), getSecondaryColor().getGreen(), | |
201 | getSecondaryColor().getBlue()); | |
202 | mainTemplate = String | |
203 | .format("<html>" | |
204 | + "<body style='width: %d px; height: %d px; text-align: center;'>" | |
205 | + "${MAIN}" + "<br>" + "<span style='color: %s;'>" | |
206 | + "${SECONDARY}" + "</span>" + "</body>" | |
207 | + "</html>", getTextWidth(), getTextHeight(), color); | |
208 | ||
209 | int ww = Math.max(getCoverWidth(), getTextWidth()); | |
210 | int hh = getCoverHeight() + getCoverVOffset() + getHGap() | |
211 | + getTextHeight(); | |
212 | ||
213 | JPanel placeholder = new JPanel(); | |
214 | placeholder | |
215 | .setPreferredSize(new Dimension(ww, hh - getTextHeight())); | |
216 | placeholder.setOpaque(false); | |
217 | ||
218 | JPanel titlePanel = new JPanel(new BorderLayout()); | |
219 | titlePanel.setOpaque(false); | |
220 | titlePanel.add(title, BorderLayout.NORTH); | |
221 | ||
222 | titlePanel.setBorder(BorderFactory.createEmptyBorder()); | |
223 | ||
224 | setLayout(new BorderLayout()); | |
225 | add(placeholder, BorderLayout.NORTH); | |
226 | add(titlePanel, BorderLayout.CENTER); | |
227 | } | |
228 | ||
229 | // Cached values are NULL, so it will be updated | |
230 | updateData(); | |
231 | } | |
232 | ||
233 | /** | |
234 | * The book current selection state. | |
235 | * | |
236 | * @return the selection state | |
237 | */ | |
238 | public boolean isSelected() { | |
239 | return selected; | |
240 | } | |
241 | ||
242 | /** | |
243 | * The book current selection state, | |
244 | * | |
245 | * @param selected | |
246 | * TRUE if it is selected | |
247 | */ | |
248 | public void setSelected(boolean selected) { | |
249 | if (this.selected != selected) { | |
250 | this.selected = selected; | |
251 | repaint(); | |
252 | } | |
253 | } | |
254 | ||
255 | /** | |
256 | * The item mouse-hover state. | |
257 | * | |
258 | * @return TRUE if it is mouse-hovered | |
259 | */ | |
260 | public boolean isHovered() { | |
261 | return this.hovered; | |
262 | } | |
263 | ||
264 | /** | |
265 | * The item mouse-hover state. | |
266 | * | |
267 | * @param hovered | |
268 | * TRUE if it is mouse-hovered | |
269 | */ | |
270 | public void setHovered(boolean hovered) { | |
271 | if (this.hovered != hovered) { | |
272 | this.hovered = hovered; | |
273 | repaint(); | |
274 | } | |
275 | } | |
276 | ||
277 | /** | |
278 | * Update the title, paint the item. | |
279 | */ | |
280 | @Override | |
281 | public void paint(Graphics g) { | |
282 | Rectangle clip = g.getClipBounds(); | |
283 | if (clip == null || clip.getWidth() <= 0 || clip.getHeight() <= 0) { | |
284 | return; | |
285 | } | |
286 | ||
287 | updateData(); | |
288 | ||
289 | super.paint(g); | |
290 | if (hasImage) { | |
291 | Image img = image == null ? getBlank(false) : image; | |
292 | if (isImageError()) | |
293 | img = getBlank(true); | |
294 | ||
295 | int xOff = getCoverHOffset() + (getWidth() - getCoverWidth()) / 2; | |
296 | g.drawImage(img, xOff, getCoverVOffset(), null); | |
297 | ||
298 | Integer status = getStatus(); | |
299 | boolean filled = status != null && status > 0; | |
300 | Color indicatorColor = getStatusIndicatorColor(status); | |
301 | if (indicatorColor != null) { | |
302 | UIUtils.drawEllipse3D(g, indicatorColor, getCoverWidth() + xOff | |
303 | + 10, 10, 20, 20, filled); | |
304 | } | |
305 | } | |
306 | ||
307 | Color bg = getOverlayColor(isEnabled(), isSelected(), isHovered()); | |
308 | g.setColor(bg); | |
309 | g.fillRect(clip.x, clip.y, clip.width, clip.height); | |
310 | } | |
311 | ||
312 | /** | |
313 | * The image to display on image {@link Item} (NULL for non-image | |
314 | * {@link Item}s). | |
315 | * | |
316 | * @return the image or NULL for the empty image or for non image | |
317 | * {@link Item}s | |
318 | */ | |
319 | public Image getImage() { | |
320 | return hasImage ? image : null; | |
321 | } | |
322 | ||
323 | /** | |
324 | * Change the image to display (does not work for non-image {@link Item}s). | |
325 | * <p> | |
326 | * NULL is allowed, an empty image will then be shown. | |
327 | * | |
328 | * @param image | |
329 | * the new {@link Image} or NULL | |
330 | * | |
331 | */ | |
332 | public void setImage(Image image) { | |
333 | this.image = hasImage ? image : null; | |
334 | } | |
335 | ||
336 | /** | |
337 | * Use the ERROR image instead of the real one or the empty one. | |
338 | * | |
339 | * @return TRUE if we force use the error image | |
340 | */ | |
341 | public boolean isImageError() { | |
342 | return imageError; | |
343 | } | |
344 | ||
345 | /** | |
346 | * Use the ERROR image instead of the real one or the empty one. | |
347 | * | |
348 | * @param imageError | |
349 | * TRUE to force use the error image | |
350 | */ | |
351 | public void setImageError(boolean imageError) { | |
352 | this.imageError = imageError; | |
353 | } | |
354 | ||
355 | /** | |
356 | * Make the given {@link String} display-ready (i.e., shorten it if it is | |
357 | * too long). | |
358 | * | |
359 | * @param value | |
360 | * the full value | |
361 | * | |
362 | * @return the display-ready value | |
363 | */ | |
364 | protected String limit(String value) { | |
365 | if (value == null) | |
366 | value = ""; | |
367 | ||
368 | if (value.length() > getMaxDisplaySize()) { | |
369 | value = value.substring(0, getMaxDisplaySize() - 3) + "..."; | |
370 | } | |
371 | ||
372 | return value; | |
373 | } | |
374 | ||
375 | /** | |
376 | * Update the title with the currently registered information. | |
377 | */ | |
378 | private void updateData() { | |
379 | String main = getMainInfoDisplay(); | |
380 | String optSecondary = getSecondaryInfoDisplay(); | |
381 | Integer status = getStatus(); | |
382 | ||
383 | // Cached values can be NULL the first time | |
384 | if (!main.equals(cachedMain) | |
385 | || !optSecondary.equals(cachedOptSecondary) | |
386 | || status != cachedStatus) { | |
387 | title.setText(mainTemplate // | |
388 | .replace("${MAIN}", main) // | |
389 | .replace("${SECONDARY}", optSecondary) // | |
390 | ); | |
391 | secondary.setText(secondaryTemplate// | |
392 | .replace("${MAIN}", main) // | |
393 | .replace("${SECONDARY}", optSecondary) // | |
394 | + " "); | |
395 | ||
396 | Color bg = getOverlayColor(isEnabled(), isSelected(), isHovered()); | |
397 | setBackground(bg); | |
398 | ||
399 | if (!hasImage) { | |
400 | remove(statusIndicatorUnknown); | |
401 | remove(statusIndicatorOn); | |
402 | remove(statusIndicatorOff); | |
403 | ||
404 | Color k = getStatusIndicatorColor(getStatus()); | |
405 | JComponent statusIndicator = statuses.get(k); | |
406 | if (!statuses.containsKey(k)) { | |
407 | statusIndicator = generateStatusIndicator(k); | |
408 | statuses.put(k, statusIndicator); | |
409 | } | |
410 | ||
411 | if (statusIndicator != null) | |
412 | add(statusIndicator, BorderLayout.WEST); | |
413 | } | |
414 | ||
415 | validate(); | |
416 | } | |
417 | ||
418 | this.cachedMain = main; | |
419 | this.cachedOptSecondary = optSecondary; | |
420 | this.cachedStatus = status; | |
421 | } | |
422 | ||
423 | /** | |
424 | * Generate a status indicator for the given colour. | |
425 | * | |
426 | * @param color | |
427 | * the colour to use | |
428 | * | |
429 | * @return a status indicator ready to be used | |
430 | */ | |
76a2516a | 431 | private JLabel generateStatusIndicator(final Color color) { |
d831b327 NR |
432 | JLabel indicator = new JLabel(" ") { |
433 | private static final long serialVersionUID = 1L; | |
434 | ||
435 | @Override | |
436 | public void paint(Graphics g) { | |
437 | super.paint(g); | |
438 | ||
439 | if (color != null) { | |
440 | Dimension sz = statusIndicatorOn.getSize(); | |
441 | int s = Math.min(sz.width, sz.height); | |
442 | int x = Math.max(0, (sz.width - sz.height) / 2); | |
443 | int y = Math.max(0, (sz.height - sz.width) / 2); | |
444 | ||
445 | UIUtils.drawEllipse3D(g, color, x, y, s, s, true); | |
446 | } | |
447 | } | |
448 | }; | |
449 | ||
450 | indicator.setBackground(color); | |
451 | return indicator; | |
452 | } | |
453 | ||
454 | private Image getBlank(boolean error) { | |
455 | Dimension key = new Dimension(getCoverWidth(), getCoverHeight()); | |
456 | Map<Dimension, BufferedImage> images = error ? Item.error : Item.empty; | |
457 | ||
458 | BufferedImage blank = images.get(key); | |
459 | if (blank == null) { | |
460 | blank = new BufferedImage(getCoverWidth(), getCoverHeight(), | |
461 | BufferedImage.TYPE_4BYTE_ABGR); | |
462 | ||
463 | Graphics2D g = blank.createGraphics(); | |
464 | try { | |
465 | g.setColor(Color.white); | |
466 | g.fillRect(0, 0, getCoverWidth(), getCoverHeight()); | |
467 | ||
468 | g.setColor(error ? Color.red : Color.black); | |
469 | g.drawLine(0, 0, getCoverWidth(), getCoverHeight()); | |
470 | g.drawLine(getCoverWidth(), 0, 0, getCoverHeight()); | |
471 | } finally { | |
472 | g.dispose(); | |
473 | } | |
474 | images.put(key, blank); | |
475 | } | |
476 | ||
477 | return blank; | |
478 | } | |
479 | } |