137897aee45b68bbc682eae949fa8fcd9e15c230
[fanfix.git] / ui / ListModel.java
1 package be.nikiroo.utils.ui;
2
3 import java.awt.Component;
4 import java.awt.Point;
5 import java.awt.Window;
6 import java.awt.event.MouseAdapter;
7 import java.awt.event.MouseEvent;
8 import java.util.ArrayList;
9 import java.util.Collection;
10 import java.util.List;
11
12 import javax.swing.JList;
13 import javax.swing.JPopupMenu;
14 import javax.swing.ListCellRenderer;
15 import javax.swing.SwingWorker;
16
17 import be.nikiroo.utils.compat.DefaultListModel6;
18 import be.nikiroo.utils.compat.JList6;
19 import be.nikiroo.utils.compat.ListCellRenderer6;
20
21 /**
22 * A {@link javax.swing.ListModel} that can maintain 2 lists; one with the
23 * actual data (the elements), and a second one with the items that are
24 * currently displayed (the items).
25 * <p>
26 * It also offers filter options, supports hovered changes and some more utility
27 * functions.
28 *
29 * @author niki
30 *
31 * @param <T>
32 * the type of elements and items (the same type)
33 */
34 public class ListModel<T> extends DefaultListModel6<T> {
35 private static final long serialVersionUID = 1L;
36
37 /** How long to wait before displaying a tooltip, in milliseconds. */
38 private static final int DELAY_TOOLTIP_MS = 1000;
39
40 /**
41 * A filter interface, to check for a condition (note that a Predicate class
42 * already exists in Java 1.8+, and is compatible with this one if you
43 * change the signatures -- but I support java 1.6+).
44 *
45 * @author niki
46 *
47 * @param <T>
48 * the type of elements and items (the same type)
49 */
50 public interface Predicate<T> {
51 /**
52 * Check if an item or an element pass a filter.
53 *
54 * @param item
55 * the item to test
56 *
57 * @return TRUE if the test passed, FALSE if not
58 */
59 public boolean test(T item);
60 }
61
62 /**
63 * A simple interface your elements must implement if you want to use
64 * {@link ListModel#generateRenderer(ListModel)}.
65 *
66 * @author niki
67 */
68 public interface Hoverable {
69 /**
70 * The element is currently selected.
71 *
72 * @param selected
73 * TRUE for selected, FALSE for unselected
74 */
75 public void setSelected(boolean selected);
76
77 /**
78 * The element is currently under the mouse cursor.
79 *
80 * @param hovered
81 * TRUE if it is, FALSE if not
82 */
83 public void setHovered(boolean hovered);
84 }
85
86 /**
87 * An interface required to support tooltips on this {@link ListModel}.
88 *
89 * @author niki
90 *
91 * @param <T>
92 * the type of elements and items (the same type)
93 */
94 public interface TooltipCreator<T> {
95 /**
96 * Generate a tooltip {@link Window} for this element.
97 * <p>
98 * Note that the tooltip can be of two modes: undecorated or standalone.
99 * An undecorated tooltip will be taken care of by this
100 * {@link ListModel}, but a standalone one is supposed to be its own
101 * Dialog or Frame (it won't be automatically closed).
102 *
103 * @param t
104 * the element to generate a tooltip for
105 * @param undecorated
106 * TRUE for undecorated tooltip, FALSE for standalone
107 * tooltips
108 *
109 * @return the generated tooltip or NULL for none
110 */
111 public Window generateTooltip(T t, boolean undecorated);
112 }
113
114 private int hoveredIndex;
115 private List<T> items = new ArrayList<T>();
116 private boolean keepSelection = true;
117
118 private TooltipCreator<T> tooltipCreator;
119 private Window tooltip;
120
121 @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
122 private JList list;
123
124 /**
125 * Create a new {@link ListModel}.
126 *
127 * @param list
128 * the {@link JList6} we will handle the data of (cannot be NULL)
129 */
130 @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
131 public ListModel(JList6<T> list) {
132 this((JList) list);
133 }
134
135 /**
136 * Create a new {@link ListModel}.
137 *
138 * @param list
139 * the {@link JList6} we will handle the data of (cannot be NULL)
140 * @param popup
141 * the popup to use and keep track of (can be NULL)
142 */
143 @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
144 public ListModel(JList6<T> list, JPopupMenu popup) {
145 this((JList) list, popup);
146 }
147
148 /**
149 * Create a new {@link ListModel}.
150 *
151 * @param list
152 * the {@link JList6} we will handle the data of (cannot be NULL)
153 * @param tooltipCreator
154 * use this if you want the list to display tooltips on hover
155 * (can be NULL)
156 */
157 @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
158 public ListModel(JList6<T> list, TooltipCreator<T> tooltipCreator) {
159 this((JList) list, null, tooltipCreator);
160 }
161
162 /**
163 * Create a new {@link ListModel}.
164 *
165 * @param list
166 * the {@link JList6} we will handle the data of (cannot be NULL)
167 * @param popup
168 * the popup to use and keep track of (can be NULL)
169 * @param tooltipCreator
170 * use this if you want the list to display tooltips on hover
171 * (can be NULL)
172 */
173 @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
174 public ListModel(JList6<T> list, JPopupMenu popup,
175 TooltipCreator<T> tooltipCreator) {
176 this((JList) list, popup, tooltipCreator);
177 }
178
179 /**
180 * Create a new {@link ListModel}.
181 * <p>
182 * Note that you must take care of passing a {@link JList} that only handles
183 * elements of the type of this {@link ListModel} -- you can also use
184 * {@link ListModel#ListModel(JList6)} instead.
185 *
186 * @param list
187 * the {@link JList} we will handle the data of (cannot be NULL,
188 * must only contain elements of the type of this
189 * {@link ListModel})
190 */
191 @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
192 public ListModel(JList list) {
193 this(list, null, null);
194 }
195
196 /**
197 * Create a new {@link ListModel}.
198 * <p>
199 * Note that you must take care of passing a {@link JList} that only handles
200 * elements of the type of this {@link ListModel} -- you can also use
201 * {@link ListModel#ListModel(JList6, JPopupMenu)} instead.
202 *
203 * @param list
204 * the {@link JList} we will handle the data of (cannot be NULL,
205 * must only contain elements of the type of this
206 * {@link ListModel})
207 * @param popup
208 * the popup to use and keep track of (can be NULL)
209 */
210 @SuppressWarnings("rawtypes") // JList<?> not in Java 1.6
211 public ListModel(JList list, JPopupMenu popup) {
212 this(list, popup, null);
213 }
214
215 /**
216 * Create a new {@link ListModel}.
217 * <p>
218 * Note that you must take care of passing a {@link JList} that only handles
219 * elements of the type of this {@link ListModel} -- you can also use
220 * {@link ListModel#ListModel(JList6, JPopupMenu)} instead.
221 *
222 * @param list
223 * the {@link JList} we will handle the data of (cannot be NULL,
224 * must only contain elements of the type of this
225 * {@link ListModel})
226 * @param tooltipCreator
227 * use this if you want the list to display tooltips on hover
228 * (can be NULL)
229 */
230 @SuppressWarnings("rawtypes") // JList<?> not in Java 1.6
231 public ListModel(JList list, TooltipCreator<T> tooltipCreator) {
232 this(list, null, tooltipCreator);
233 }
234
235 /**
236 * Create a new {@link ListModel}.
237 * <p>
238 * Note that you must take care of passing a {@link JList} that only handles
239 * elements of the type of this {@link ListModel} -- you can also use
240 * {@link ListModel#ListModel(JList6, JPopupMenu)} instead.
241 *
242 * @param list
243 * the {@link JList} we will handle the data of (cannot be NULL,
244 * must only contain elements of the type of this
245 * {@link ListModel})
246 * @param popup
247 * the popup to use and keep track of (can be NULL)
248 * @param tooltipCreator
249 * use this if you want the list to display tooltips on hover
250 * (can be NULL)
251 */
252 @SuppressWarnings({ "unchecked", "rawtypes" }) // JList<?> not in Java 1.6
253 public ListModel(final JList list, final JPopupMenu popup,
254 TooltipCreator<T> tooltipCreator) {
255 this.list = list;
256 this.tooltipCreator = tooltipCreator;
257
258 list.setModel(this);
259
260 final DelayWorker tooltipWatcher = new DelayWorker(DELAY_TOOLTIP_MS);
261 if (tooltipCreator != null) {
262 tooltipWatcher.start();
263 }
264
265 list.addMouseMotionListener(new MouseAdapter() {
266 @Override
267 public void mouseMoved(final MouseEvent me) {
268 if (popup != null && popup.isShowing())
269 return;
270
271 Point p = new Point(me.getX(), me.getY());
272 final int index = list.locationToIndex(p);
273 if (index != hoveredIndex) {
274 int oldIndex = hoveredIndex;
275 hoveredIndex = index;
276 fireElementChanged(oldIndex);
277 fireElementChanged(index);
278
279 synchronized (tooltipWatcher) {
280 if (tooltip != null) {
281 tooltip.setVisible(false);
282 }
283 tooltip = null;
284 }
285
286 if (ListModel.this.tooltipCreator != null) {
287 tooltipWatcher.delay("tooltip",
288 new SwingWorker<Void, Void>() {
289 @Override
290 protected Void doInBackground()
291 throws Exception {
292 return null;
293 }
294
295 @Override
296 protected void done() {
297 synchronized (tooltipWatcher) {
298 if (tooltip != null) {
299 tooltip.setVisible(false);
300 }
301 tooltip = null;
302 }
303
304 if (index < 0
305 || index != hoveredIndex) {
306 return;
307 }
308
309 if (popup != null
310 && popup.isShowing()) {
311 return;
312 }
313
314 synchronized (tooltipWatcher) {
315 if (tooltip != null) {
316 tooltip.setVisible(false);
317 }
318 tooltip = newTooltip(index, me);
319 }
320 }
321 });
322 }
323 }
324 }
325 });
326
327 list.addMouseListener(new MouseAdapter() {
328 @Override
329 public void mousePressed(MouseEvent e) {
330 check(e);
331 }
332
333 @Override
334 public void mouseReleased(MouseEvent e) {
335 check(e);
336 }
337
338 @Override
339 public void mouseExited(MouseEvent e) {
340 if (popup != null && popup.isShowing())
341 return;
342
343 if (hoveredIndex > -1) {
344 int oldIndex = hoveredIndex;
345 hoveredIndex = -1;
346 fireElementChanged(oldIndex);
347 }
348 }
349
350 private void check(MouseEvent e) {
351 if (popup == null) {
352 return;
353 }
354
355 if (e.isPopupTrigger()) {
356 if (list.getSelectedIndices().length <= 1) {
357 list.setSelectedIndex(
358 list.locationToIndex(e.getPoint()));
359 }
360
361 Window oldTooltip = tooltip;
362 tooltip = null;
363 if (oldTooltip != null) {
364 oldTooltip.setVisible(false);
365 }
366
367 popup.show(list, e.getX(), e.getY());
368 }
369 }
370
371 });
372 }
373
374 /**
375 * (Try and) keep the elements that were selected when filtering.
376 * <p>
377 * This will use toString on the elements to identify them, and can be a bit
378 * resource intensive.
379 *
380 * @return TRUE if we do
381 */
382 public boolean isKeepSelection() {
383 return keepSelection;
384 }
385
386 /**
387 * (Try and) keep the elements that were selected when filtering.
388 * <p>
389 * This will use toString on the elements to identify them, and can be a bit
390 * resource intensive.
391 *
392 * @param keepSelection
393 * TRUE to try and keep them selected
394 */
395 public void setKeepSelection(boolean keepSelection) {
396 this.keepSelection = keepSelection;
397 }
398
399 /**
400 * Check if this element is currently under the mouse.
401 *
402 * @param element
403 * the element to check
404 *
405 * @return TRUE if it is
406 */
407 public boolean isHovered(T element) {
408 return indexOf(element) == hoveredIndex;
409 }
410
411 /**
412 * Check if this element is currently under the mouse.
413 *
414 * @param index
415 * the index of the element to check
416 *
417 * @return TRUE if it is
418 */
419 public boolean isHovered(int index) {
420 return index == hoveredIndex;
421 }
422
423 /**
424 * Add an item to the model.
425 *
426 * @param item
427 * the new item to add
428 */
429 public void addItem(T item) {
430 items.add(item);
431 }
432
433 /**
434 * Add items to the model.
435 *
436 * @param items
437 * the new items to add
438 */
439 public void addAllItems(Collection<T> items) {
440 this.items.addAll(items);
441 }
442
443 /**
444 * Removes the first occurrence of the specified element from this list, if
445 * it is present (optional operation).
446 *
447 * @param item
448 * the item to remove if possible (can be NULL)
449 *
450 * @return TRUE if one element was removed, FALSE if not found
451 */
452 public boolean removeItem(T item) {
453 return items.remove(item);
454 }
455
456 /**
457 * Remove the items that pass the given filter (or all items if the filter
458 * is NULL).
459 *
460 * @param filter
461 * the filter (if the filter returns TRUE, the item will be
462 * removed)
463 *
464 * @return TRUE if at least one item was removed
465 */
466 public boolean removeItemIf(Predicate<T> filter) {
467 boolean changed = false;
468 if (filter == null) {
469 changed = !items.isEmpty();
470 clearItems();
471 } else {
472 for (int i = 0; i < items.size(); i++) {
473 if (filter.test(items.get(i))) {
474 items.remove(i--);
475 changed = true;
476 }
477 }
478 }
479
480 return changed;
481 }
482
483 /**
484 * Removes all the items from this model.
485 */
486 public void clearItems() {
487 items.clear();
488 }
489
490 /**
491 * Filter the current elements.
492 * <p>
493 * This method will clear all the elements then look into all the items:
494 * those that pass the given filter will be copied as elements.
495 *
496 * @param filter
497 * the filter to select which elements to keep; an item that pass
498 * the filter will be copied as an element (can be NULL, in that
499 * case all items will be copied as elements)
500 */
501 @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
502 public void filter(Predicate<T> filter) {
503 ListSnapshot snapshot = null;
504
505 if (keepSelection)
506 snapshot = new ListSnapshot(list);
507
508 clear();
509 for (T item : items) {
510 if (filter == null || filter.test(item)) {
511 addElement(item);
512 }
513 }
514
515 if (keepSelection)
516 snapshot.apply();
517
518 list.repaint();
519 }
520
521 /**
522 * Return the currently selected elements.
523 *
524 * @return the selected elements
525 */
526 public List<T> getSelectedElements() {
527 List<T> selected = new ArrayList<T>();
528 for (int index : list.getSelectedIndices()) {
529 selected.add(get(index));
530 }
531
532 return selected;
533 }
534
535 /**
536 * Return the selected element if <b>one</b> and <b>only one</b> element is
537 * selected. I.E., if zero, two or more elements are selected, NULL will be
538 * returned.
539 *
540 * @return the element if it is the only selected element, NULL otherwise
541 */
542 public T getUniqueSelectedElement() {
543 List<T> selected = getSelectedElements();
544 if (selected.size() == 1) {
545 return selected.get(0);
546 }
547
548 return null;
549 }
550
551 /**
552 * Notify that this element has been changed.
553 *
554 * @param index
555 * the index of the element
556 */
557 public void fireElementChanged(int index) {
558 if (index >= 0) {
559 fireContentsChanged(this, index, index);
560 }
561 }
562
563 /**
564 * Notify that this element has been changed.
565 *
566 * @param element
567 * the element
568 */
569 public void fireElementChanged(T element) {
570 int index = indexOf(element);
571 if (index >= 0) {
572 fireContentsChanged(this, index, index);
573 }
574 }
575
576 @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
577 @Override
578 public T get(int index) {
579 return (T) super.get(index);
580 }
581
582 private Window newTooltip(final int index, final MouseEvent me) {
583 final T value = ListModel.this.get(index);
584
585 final Window newTooltip = tooltipCreator.generateTooltip(value, true);
586
587 if (newTooltip != null) {
588 newTooltip.addMouseListener(new MouseAdapter() {
589 @Override
590 public void mouseClicked(MouseEvent e) {
591 Window promotedTooltip = tooltipCreator
592 .generateTooltip(value, false);
593 if (promotedTooltip != null) {
594 promotedTooltip.setLocation(me.getXOnScreen(),
595 me.getYOnScreen());
596 promotedTooltip.setVisible(true);
597 }
598
599 newTooltip.setVisible(false);
600 }
601 });
602
603 newTooltip.setLocation(me.getXOnScreen(), me.getYOnScreen());
604 newTooltip.setVisible(true);
605 }
606
607 return newTooltip;
608 }
609
610 /**
611 * Generate a {@link ListCellRenderer} that supports {@link Hoverable}
612 * elements.
613 *
614 * @param <T>
615 * the type of elements and items (the same type), which should
616 * implement {@link Hoverable} (it will not cause issues if not,
617 * but then, it will be a default renderer)
618 * @param model
619 * the model to use
620 *
621 * @return a suitable, {@link Hoverable} compatible renderer
622 */
623 static public <T extends Component> ListCellRenderer6<T> generateRenderer(
624 final ListModel<T> model) {
625 return new ListCellRenderer6<T>() {
626 @Override
627 public Component getListCellRendererComponent(JList6<T> list,
628 T item, int index, boolean isSelected,
629 boolean cellHasFocus) {
630 if (item instanceof Hoverable) {
631 Hoverable hoverable = (Hoverable) item;
632 hoverable.setSelected(isSelected);
633 hoverable.setHovered(model.isHovered(index));
634 }
635
636 return item;
637 }
638 };
639 }
640 }