Merge branch 'subtree'
[nikiroo-utils.git] / src / be / nikiroo / utils / 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.ui.compat.DefaultListModel6;
18 import be.nikiroo.utils.ui.compat.JList6;
19 import be.nikiroo.utils.ui.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 DelayWorker tooltipWatcher;
119 private JPopupMenu popup;
120 private TooltipCreator<T> tooltipCreator;
121 private Window tooltip;
122
123 @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
124 private JList list;
125
126 /**
127 * Create a new {@link ListModel}.
128 *
129 * @param list
130 * the {@link JList6} we will handle the data of (cannot be NULL)
131 */
132 @SuppressWarnings("rawtypes") // JList<?> not compatible Java 1.6
133 public ListModel(JList6<T> list) {
134 this((JList) list);
135 }
136
137 /**
138 * Create a new {@link ListModel}.
139 * <p>
140 * Note that you must take care of passing a {@link JList} that only handles
141 * elements of the type of this {@link ListModel} -- you can also use
142 * {@link ListModel#ListModel(JList6)} instead.
143 *
144 * @param list
145 * the {@link JList} we will handle the data of (cannot be NULL,
146 * must only contain elements of the type of this
147 * {@link ListModel})
148 */
149 @SuppressWarnings({ "unchecked", "rawtypes" }) // JList<?> not in Java 1.6
150 public ListModel(final JList list) {
151 this.list = list;
152
153 list.setModel(this);
154
155 // We always have it ready
156 tooltipWatcher = new DelayWorker(DELAY_TOOLTIP_MS);
157 tooltipWatcher.start();
158
159 list.addMouseMotionListener(new MouseAdapter() {
160 @Override
161 public void mouseMoved(final MouseEvent me) {
162 if (ListModel.this.popup != null
163 && ListModel.this.popup.isShowing())
164 return;
165
166 Point p = new Point(me.getX(), me.getY());
167 final int index = list.locationToIndex(p);
168 if (index != hoveredIndex) {
169 int oldIndex = hoveredIndex;
170 hoveredIndex = index;
171 fireElementChanged(oldIndex);
172 fireElementChanged(index);
173
174 if (ListModel.this.tooltipCreator != null) {
175 showTooltip(null);
176
177 tooltipWatcher.delay("tooltip",
178 new SwingWorker<Void, Void>() {
179 @Override
180 protected Void doInBackground()
181 throws Exception {
182 return null;
183 }
184
185 @Override
186 protected void done() {
187 showTooltip(null);
188
189 if (index < 0
190 || index != hoveredIndex) {
191 return;
192 }
193
194 if (ListModel.this.popup != null
195 && ListModel.this.popup
196 .isShowing()) {
197 return;
198 }
199
200 showTooltip(newTooltip(index, me));
201 }
202 });
203 }
204 }
205 }
206 });
207
208 list.addMouseListener(new MouseAdapter() {
209 @Override
210 public void mousePressed(MouseEvent e) {
211 check(e);
212 }
213
214 @Override
215 public void mouseReleased(MouseEvent e) {
216 check(e);
217 }
218
219 @Override
220 public void mouseExited(MouseEvent e) {
221 if (ListModel.this.popup != null
222 && ListModel.this.popup.isShowing())
223 return;
224
225 if (hoveredIndex > -1) {
226 int oldIndex = hoveredIndex;
227 hoveredIndex = -1;
228 fireElementChanged(oldIndex);
229 }
230 }
231
232 private void check(MouseEvent e) {
233 if (ListModel.this.popup == null) {
234 return;
235 }
236
237 if (e.isPopupTrigger()) {
238 if (list.getSelectedIndices().length <= 1) {
239 list.setSelectedIndex(
240 list.locationToIndex(e.getPoint()));
241 }
242
243 showTooltip(null);
244 ListModel.this.popup.show(list, e.getX(), e.getY());
245 }
246 }
247
248 });
249 }
250
251 /**
252 * (Try and) keep the elements that were selected when filtering.
253 * <p>
254 * This will use toString on the elements to identify them, and can be a bit
255 * resource intensive.
256 *
257 * @return TRUE if we do
258 */
259 public boolean isKeepSelection() {
260 return keepSelection;
261 }
262
263 /**
264 * (Try and) keep the elements that were selected when filtering.
265 * <p>
266 * This will use toString on the elements to identify them, and can be a bit
267 * resource intensive.
268 *
269 * @param keepSelection
270 * TRUE to try and keep them selected
271 */
272 public void setKeepSelection(boolean keepSelection) {
273 this.keepSelection = keepSelection;
274 }
275
276 /**
277 * The popup to use and keep track of (can be NULL).
278 *
279 * @return the current popup
280 */
281 public JPopupMenu getPopup() {
282 return popup;
283 }
284
285 /**
286 * The popup to use and keep track of (can be NULL).
287 *
288 * @param popup
289 * the new popup
290 */
291 public void setPopup(JPopupMenu popup) {
292 this.popup = popup;
293 }
294
295 /**
296 * You can use a {@link TooltipCreator} if you want the list to display
297 * tooltips on mouse hover (can be NULL).
298 *
299 * @return the current {@link TooltipCreator}
300 */
301 public TooltipCreator<T> getTooltipCreator() {
302 return tooltipCreator;
303 }
304
305 /**
306 * You can use a {@link TooltipCreator} if you want the list to display
307 * tooltips on mouse hover (can be NULL).
308 *
309 * @param tooltipCreator
310 * the new {@link TooltipCreator}
311 */
312 public void setTooltipCreator(TooltipCreator<T> tooltipCreator) {
313 this.tooltipCreator = tooltipCreator;
314 }
315
316 /**
317 * Check if this element is currently under the mouse.
318 *
319 * @param element
320 * the element to check
321 *
322 * @return TRUE if it is
323 */
324 public boolean isHovered(T element) {
325 return indexOf(element) == hoveredIndex;
326 }
327
328 /**
329 * Check if this element is currently under the mouse.
330 *
331 * @param index
332 * the index of the element to check
333 *
334 * @return TRUE if it is
335 */
336 public boolean isHovered(int index) {
337 return index == hoveredIndex;
338 }
339
340 /**
341 * Add an item to the model.
342 *
343 * @param item
344 * the new item to add
345 */
346 public void addItem(T item) {
347 items.add(item);
348 }
349
350 /**
351 * Add items to the model.
352 *
353 * @param items
354 * the new items to add
355 */
356 public void addAllItems(Collection<T> items) {
357 this.items.addAll(items);
358 }
359
360 /**
361 * Removes the first occurrence of the specified element from this list, if
362 * it is present (optional operation).
363 *
364 * @param item
365 * the item to remove if possible (can be NULL)
366 *
367 * @return TRUE if one element was removed, FALSE if not found
368 */
369 public boolean removeItem(T item) {
370 return items.remove(item);
371 }
372
373 /**
374 * Remove the items that pass the given filter (or all items if the filter
375 * is NULL).
376 *
377 * @param filter
378 * the filter (if the filter returns TRUE, the item will be
379 * removed)
380 *
381 * @return TRUE if at least one item was removed
382 */
383 public boolean removeItemIf(Predicate<T> filter) {
384 boolean changed = false;
385 if (filter == null) {
386 changed = !items.isEmpty();
387 clearItems();
388 } else {
389 for (int i = 0; i < items.size(); i++) {
390 if (filter.test(items.get(i))) {
391 items.remove(i--);
392 changed = true;
393 }
394 }
395 }
396
397 return changed;
398 }
399
400 /**
401 * Removes all the items from this model.
402 */
403 public void clearItems() {
404 items.clear();
405 }
406
407 /**
408 * Filter the current elements.
409 * <p>
410 * This method will clear all the elements then look into all the items:
411 * those that pass the given filter will be copied as elements.
412 *
413 * @param filter
414 * the filter to select which elements to keep; an item that pass
415 * the filter will be copied as an element (can be NULL, in that
416 * case all items will be copied as elements)
417 */
418 @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
419 public void filter(Predicate<T> filter) {
420 ListSnapshot snapshot = null;
421
422 if (keepSelection)
423 snapshot = new ListSnapshot(list);
424
425 clear();
426 for (T item : items) {
427 if (filter == null || filter.test(item)) {
428 addElement(item);
429 }
430 }
431
432 if (keepSelection)
433 snapshot.apply();
434
435 list.repaint();
436 }
437
438 /**
439 * Return the currently selected elements.
440 *
441 * @return the selected elements
442 */
443 public List<T> getSelectedElements() {
444 List<T> selected = new ArrayList<T>();
445 for (int index : list.getSelectedIndices()) {
446 selected.add(get(index));
447 }
448
449 return selected;
450 }
451
452 /**
453 * Return the selected element if <b>one</b> and <b>only one</b> element is
454 * selected. I.E., if zero, two or more elements are selected, NULL will be
455 * returned.
456 *
457 * @return the element if it is the only selected element, NULL otherwise
458 */
459 public T getUniqueSelectedElement() {
460 List<T> selected = getSelectedElements();
461 if (selected.size() == 1) {
462 return selected.get(0);
463 }
464
465 return null;
466 }
467
468 /**
469 * Notify that this element has been changed.
470 *
471 * @param index
472 * the index of the element
473 */
474 public void fireElementChanged(int index) {
475 if (index >= 0) {
476 fireContentsChanged(this, index, index);
477 }
478 }
479
480 /**
481 * Notify that this element has been changed.
482 *
483 * @param element
484 * the element
485 */
486 public void fireElementChanged(T element) {
487 int index = indexOf(element);
488 if (index >= 0) {
489 fireContentsChanged(this, index, index);
490 }
491 }
492
493 @SuppressWarnings("unchecked") // JList<?> not compatible Java 1.6
494 @Override
495 public T get(int index) {
496 return (T) super.get(index);
497 }
498
499 private Window newTooltip(final int index, final MouseEvent me) {
500 final T value = ListModel.this.get(index);
501 final Window newTooltip = tooltipCreator.generateTooltip(value, true);
502 if (newTooltip != null) {
503 newTooltip.addMouseListener(new MouseAdapter() {
504 @Override
505 public void mouseClicked(MouseEvent e) {
506 Window promotedTooltip = tooltipCreator
507 .generateTooltip(value, false);
508 if (promotedTooltip != null) {
509 promotedTooltip.setLocation(me.getXOnScreen(),
510 me.getYOnScreen());
511 promotedTooltip.setVisible(true);
512 }
513
514 newTooltip.setVisible(false);
515 }
516 });
517
518 newTooltip.setLocation(me.getXOnScreen(), me.getYOnScreen());
519 showTooltip(newTooltip);
520 }
521
522 return newTooltip;
523 }
524
525 private void showTooltip(Window tooltip) {
526 synchronized (tooltipWatcher) {
527 if (this.tooltip != null) {
528 this.tooltip.setVisible(false);
529 this.tooltip.dispose();
530 }
531
532 this.tooltip = tooltip;
533
534 if (tooltip != null) {
535 tooltip.setVisible(true);
536 }
537 }
538 }
539
540 /**
541 * Generate a {@link ListCellRenderer} that supports {@link Hoverable}
542 * elements.
543 *
544 * @param <T>
545 * the type of elements and items (the same type), which should
546 * implement {@link Hoverable} (it will not cause issues if not,
547 * but then, it will be a default renderer)
548 * @param model
549 * the model to use
550 *
551 * @return a suitable, {@link Hoverable} compatible renderer
552 */
553 static public <T extends Component> ListCellRenderer6<T> generateRenderer(
554 final ListModel<T> model) {
555 return new ListCellRenderer6<T>() {
556 @Override
557 public Component getListCellRendererComponent(JList6<T> list,
558 T item, int index, boolean isSelected,
559 boolean cellHasFocus) {
560 if (item instanceof Hoverable) {
561 Hoverable hoverable = (Hoverable) item;
562 hoverable.setSelected(isSelected);
563 hoverable.setHovered(model.isHovered(index));
564 }
565
566 return item;
567 }
568 };
569 }
570 }