/* * Kontalk Java client * Copyright (C) 2016 Kontalk Devteam <devteam@kontalk.org> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kontalk.view; import javax.swing.AbstractCellEditor; import javax.swing.CellEditor; import javax.swing.JTable; import javax.swing.ListSelectionModel; import javax.swing.RowFilter; import javax.swing.RowSorter; import javax.swing.SortOrder; import javax.swing.SwingUtilities; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import javax.swing.table.DefaultTableModel; import javax.swing.table.TableCellEditor; import javax.swing.table.TableRowSorter; import java.awt.Color; import java.awt.Component; import java.awt.Point; import java.awt.Rectangle; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionAdapter; import java.lang.reflect.ParameterizedType; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Observable; import java.util.Optional; import java.util.Set; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.TimeUnit; import com.alee.laf.menu.WebPopupMenu; import com.alee.laf.panel.WebPanel; import com.alee.laf.table.WebTable; import com.alee.laf.table.renderers.WebTableCellRenderer; import com.alee.managers.language.data.TooltipWay; import com.alee.managers.tooltip.TooltipManager; import com.alee.managers.tooltip.WebCustomTooltip; import org.apache.commons.lang.ArrayUtils; import org.kontalk.misc.Searchable; /** * A generic list view for subclassing. * Implemented as table with one column. * * Flyweight pattern: One object is re-used for drawing all model values in the list. * * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>} * @param <V> the (model) value type in the list */ abstract class ListView<V extends Observable & Searchable> extends WebTable implements ObserverTrait, Comparator<V> { private final Class mVClass; final View mView; private final DefaultTableModel mModel; private final TableRowSorter<DefaultTableModel> mRowSorter; /** Flyweight item that is used by cell renderer. */ protected final FlyweightItem mRenderItem; /** Flyweight item that is used by cell editor. */ protected final FlyweightItem mEditorItem; /** The current search string. */ private String mSearch = ""; private WebCustomTooltip mTip = null; // using legacy lib, raw types extend Object @SuppressWarnings("unchecked") ListView(View view, FlyweightItem renderItem, FlyweightItem editorItem, int selectionMode, boolean filterSelected, boolean activateTimer) { // damn Java mVClass = (Class<V>) ((ParameterizedType) getClass() .getGenericSuperclass()).getActualTypeArguments()[0]; mView = view; mRenderItem = renderItem; mEditorItem = editorItem; this.setSelectionMode(selectionMode); // model mModel = new DefaultTableModel(0, 1) { // row sorter needs this @Override public Class<?> getColumnClass(int columnIndex) { return ListView.this.getColumnClass(columnIndex); } }; this.setModel(mModel); // sorter mRowSorter = new TableRowSorter<>(mModel); mRowSorter.setComparator(0, this); List<RowSorter.SortKey> sortKeys = new ArrayList<>(); sortKeys.add(new RowSorter.SortKey(0, SortOrder.ASCENDING)); mRowSorter.setSortKeys(sortKeys); mRowSorter.setSortsOnUpdates(true); mRowSorter.sort(); // filter RowFilter<DefaultTableModel, Integer> rowFilter = new RowFilter<DefaultTableModel, Integer>() { @Override public boolean include(Entry<? extends DefaultTableModel, ? extends Integer> entry) { V v = (V) entry.getValue(0); return (!filterSelected && v.equals(ListView.this.getSelectedValue().orElse(null))) || v.contains(mSearch); } }; mRowSorter.setRowFilter(rowFilter); this.setRowSorter(mRowSorter); // hide header this.setTableHeader(null); // grid this.setGridColor(Color.LIGHT_GRAY); this.setShowVerticalLines(false); // use custom renderer this.setDefaultRenderer(mVClass, new TableRenderer()); // use custom editor (for mouse interaction) this.setDefaultEditor(mVClass, new TableEditor()); // trigger editing to forward mouse events this.addMouseMotionListener(new MouseMotionAdapter() { @Override public void mouseMoved(MouseEvent e) { int row = ListView.this.rowAtPoint(e.getPoint()); if (row >= 0) { ListView.this.editCellAt(row, 0); } } }); // non-static menu, cannot use this //this.setComponentPopupMenu(...); // actions triggered by mouse events this.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { check(e); } @Override public void mouseReleased(MouseEvent e) { check(e); } private void check(MouseEvent e) { if (e.isPopupTrigger()) ListView.this.onPopupClick(e); } @Override public void mouseExited(MouseEvent e) { if (mTip != null) mTip.closeTooltip(); } }); // actions triggered by key events this.addKeyListener(new KeyAdapter() { @Override public void keyReleased(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_F2){ ListView.this.onRenameEvent(); } } }); if (activateTimer) { Timer aTimer = new Timer(); // update periodically values to be up-to-date with 'last seen' text TimerTask statusTask = new TimerTask() { @Override public void run() { ListView.this.update(null, null); } }; long timerInterval = TimeUnit.SECONDS.toMillis(60); aTimer.schedule(statusTask, timerInterval, timerInterval); } // actions triggered by selection this.getSelectionModel().addListSelectionListener(new ListSelectionListener() { @Override public void valueChanged(ListSelectionEvent e) { // on selection two events are fired: first adjusting, second not. if (e.getValueIsAdjusting()) { // HACK, cell editing blocks item selection (item is not instantly selected on // click), see https://stackoverflow.com/a/17636224 CellEditor cellEditor = ListView.this.getCellEditor(); SwingUtilities.invokeLater(new Runnable() { @Override public void run() { if (cellEditor != null) cellEditor.stopCellEditing(); } }); } else { ListView.this.selectionChanged(ListView.this.getSelectedValue()); } } }); } @Override public void changeSelection(int rowIndex, int columnIndex, boolean toggle, boolean extend) { // toggle selection with normal click boolean newToggle = this.getSelectionModel().getSelectionMode() != ListSelectionModel.SINGLE_SELECTION && this.getSelectedRowCount() == 1 && this.getSelectedRow() == rowIndex || toggle; super.changeSelection(rowIndex, columnIndex, newToggle, extend); } private void onPopupClick(MouseEvent e) { int row = this.rowAtPoint(e.getPoint()); if (!ArrayUtils.contains(this.getSelectedRows(), row)) this.setSelectedItem(row); WebPopupMenu menu = this.rightClickMenu(this.getSelectedValues()); menu.show(this, e.getX(), e.getY()); } void selectionChanged(Optional<V> value){} protected abstract WebPopupMenu rightClickMenu(List<V> selectedValues); @SuppressWarnings("unchecked") boolean sync(Set<V> values) { Set<V> oldValues = new HashSet<>(); // remove old for (int i=0; i < mModel.getRowCount(); i++) { V value = (V) mModel.getValueAt(i, 0); if (!values.contains(value)) { value.deleteObserver(this); mModel.removeRow(i); i--; } else { oldValues.add(value); } } // add new boolean added = false; for (V v: values) { if (!oldValues.contains(v)) { mModel.addRow(new Object[]{v}); v.addObserver(this); added = true; } } return added; } void clearItems() { mModel.setRowCount(0); } V getDisplayedValueAt(int row) { return this.getValueAtModelIndex(mRowSorter.convertRowIndexToModel(row)); } @SuppressWarnings("unchecked") V getValueAtModelIndex(int row) { return (V) mModel.getValueAt(row, 0); } List<V> getSelectedValues() { List<V> values = new ArrayList<>(); for (int i : this.getSelectedRows()) { values.add(this.getDisplayedValueAt(i)); } return values; } Optional<V> getSelectedValue() { int row = this.getSelectedRow(); return row == -1 ? Optional.empty() : Optional.of(this.getDisplayedValueAt(row)); } /** Resets filtering and selects the item containing the value specified. */ void setSelectedItem(V value) { this.filterItems(""); for (int i=0; i< mModel.getRowCount(); i++) { if (this.getDisplayedValueAt(i) == value) { this.setSelectedItem(i); break; } } if (this.getSelectedValue().orElse(null) != value) // fallback this.setSelectedItem(0); } void setSelectedItem(int i) { if (i >= mModel.getRowCount()) return; if (i == this.getSelectedRow()) return; // weblaf does this by "clear+add", triggering two selection events // better do this on our own //this.setSelectedRow(i); this.getSelectionModel().setSelectionInterval(i, i); } void filterItems(String search) { mSearch = search; mRowSorter.sort(); } @Override public void update(Observable o, Object arg) { if (SwingUtilities.isEventDispatchThread()) { this.updateOnEDT(o, arg); return; } SwingUtilities.invokeLater(new Runnable() { @Override public void run() { ListView.this.updateOnEDT(o, arg); } }); } @SuppressWarnings("unchecked") @Override public void updateOnEDT(Observable o, Object arg) { if (o == null || mVClass.isAssignableFrom(o.getClass())) { // render everything again (and update sorting) updateRowRendering(0, this.getRowCount() -1); return; } this.updateOnEDT(arg); } void updateRowRendering(int from, int to) { from = Math.max(0, from); to = Math.max(0, to); if (this.getRowCount() > 0) mModel.fireTableRowsUpdated(from, to); } abstract protected void updateOnEDT(Object arg); // WebLaf's tooltipmanager blocks mouse events, we need to invoke the tooltip manually. // Catch the event when a tooltip should be shown and create a own one. @Override public String getToolTipText(MouseEvent event) { int row = this.rowAtPoint(event.getPoint()); if (row >= 0) ListView.this.showTooltip(this.getDisplayedValueAt(row)); return null; } String getTooltipText(V value) { return ""; } private void showTooltip(V value) { String text = this.getTooltipText(value); if (text.isEmpty()) return; Point p = this.getMousePosition(); if (p == null) return; Rectangle rec = this.getCellRect(this.rowAtPoint(p), 0, false); Point pos = new Point(rec.x + rec.width, rec.y + rec.height / 2); if (mTip != null && pos.equals(mTip.getDisplayLocation()) && mTip.isShowing()) return; if (mTip != null) mTip.closeTooltip(); mTip = TooltipManager.showOneTimeTooltip(this, pos, text, TooltipWay.right); } // JTabel uses this to determine the renderer/editor @Override public Class<?> getColumnClass(int column) { return mVClass; } void onRenameEvent() {} /** View item used as flyweight object. */ abstract static class FlyweightItem<V> extends WebPanel { /** Update before painting. */ protected abstract void render(V value, int listWidth, boolean isSelected, boolean isLast); protected void configUpdate() {}; } private class TableRenderer extends WebTableCellRenderer { // return for each item (value) in the list/table the component to // render - which is the updated render item @Override public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { return updateFlyweight(mRenderItem, table, value, row, isSelected); } } // needed for correct mouse behaviour for components in items // (and breaks selection behaviour somehow) @SuppressWarnings("unchecked") private class TableEditor extends AbstractCellEditor implements TableCellEditor { private V mValue; @Override public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { mValue = (V) value; return updateFlyweight(mEditorItem, table, value, row, isSelected); } @Override public Object getCellEditorValue() { // no idea what this is used for return mValue; } } // NOTE: table and value can be NULL @SuppressWarnings("unchecked") private static <V> FlyweightItem updateFlyweight(FlyweightItem item, JTable table, Object value, int row, boolean isSelected) { V valueItem = (V) value; // hopefully return value is not used if (table == null || valueItem == null) { return item; } boolean isLast = row == table.getRowCount() - 1; item.render(valueItem, table.getWidth(), isSelected, isLast); int height = Math.max(table.getRowHeight(), item.getPreferredSize().height); // view item needs a little more then it preferres height += 1; if (height != table.getRowHeight(row)) { // NOTE: this calls resizeAndRepaint() table.setRowHeight(row, height); } return item; } }