package chatty.gui.components.settings; import java.awt.Component; import java.awt.Dimension; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; import java.awt.Point; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.List; import java.util.regex.PatternSyntaxException; import javax.swing.AbstractAction; import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JTable; import javax.swing.JTextField; import javax.swing.KeyStroke; import javax.swing.ListSelectionModel; import javax.swing.RowFilter; import javax.swing.Timer; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import javax.swing.table.TableCellRenderer; import javax.swing.table.TableRowSorter; /** * A table containing one element per row, with editing features. * * @author tduva */ public class TableEditor<T> extends JPanel { private static final Dimension BUTTON_SIZE = new Dimension(27,27); public static final int SORTING_MODE_MANUAL = 0; public static final int SORTING_MODE_SORTED = 1; private final ButtonAction buttonActionListener = new ButtonAction(); // Table State private final JTable table; private ListTableModel<T> data; private ItemEditor<T> editor; private TableRowSorter<ListTableModel<T>> sorter; private int sortingMode; private boolean currentlyFiltering; private String search = ""; private long searchTime = 0; private int searchColumn; private final Timer searchTimer; /** * Edit buttons */ private final JButton add = new JButton(); private final JButton remove = new JButton(); private final JButton edit = new JButton(); private final JButton moveUp = new JButton(); private final JButton moveDown = new JButton(); private final JButton refresh = new JButton(); private final JTextField filterInput = new JTextField(); private TableEditorListener listener; private TableContextMenu contextMenu; /** * * The {@code sortingMode} determines the sorting features this table provides: * <ul> * <li>{@code SORTING_MODE_MANUAL} means the user can/has to order the entries * manually, which may be required for some applications</li> * <li> {@code SORTING_MODE_SORTED} means the table can sort the entries by their * natural order and items can also be filtered</li> * </ul> * * @param sortingMode The sorting mode * @param refreshButton Whether this table should have a reload button, * which may only be applicable for some uses */ public TableEditor(int sortingMode, boolean refreshButton) { this.sortingMode = sortingMode; table = new JTable(); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setFillsViewportHeight(true); table.getTableHeader().setReorderingAllowed(false); // Selection Listener to update buttons table.getSelectionModel().addListSelectionListener(new ListSelectionListener() { @Override public void valueChanged(ListSelectionEvent e) { updateButtons(); } }); // Mouse Listener to edit items and open context menu table.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() == 2) { editSelectedItem(); } } @Override public void mousePressed(MouseEvent e) { selectRowAt(e.getPoint()); popupMenu(e); } @Override public void mouseReleased(MouseEvent e) { popupMenu(e); } }); // Delete key table.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), "removeItems"); table.getActionMap().put("removeItems", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { removeSelected(); } }); table.addKeyListener(new KeyAdapter() { @Override public void keyTyped(KeyEvent e) { search(e.getKeyChar()); } }); searchTimer = new Timer(500, new ActionListener() { @Override public void actionPerformed(ActionEvent e) { checkResetSearch(); } }); searchTimer.setRepeats(true); // Buttons Configuration configureButton(add, "list-add.png", "Add selected item"); configureButton(edit, "edit.png", "Edit selected item"); configureButton(remove, "list-remove.png", "Remove selected item"); configureButton(moveUp, "go-up.png", "Move selected item up"); configureButton(moveDown, "go-down.png", "Move selected item down"); configureButton(refresh, "view-refresh.png", "Refresh data"); // Layout setLayout(new GridBagLayout()); GridBagConstraints gbc; gbc = makeGbc(0, 0, 2, 7); gbc.fill = GridBagConstraints.BOTH; gbc.weightx = 1; gbc.weighty = 1; add(new JScrollPane(table), gbc); // Filter if (sortingMode == SORTING_MODE_SORTED) { gbc = makeGbc(0, 7, 1, 1); gbc.insets = new Insets(0,2,0,1); JLabel filterInputLabel = new JLabel("Filter: "); filterInputLabel.setLabelFor(filterInput); add(filterInputLabel, gbc); gbc = makeGbc(1, 7, 1, 1); gbc.fill = GridBagConstraints.HORIZONTAL; gbc.weightx = 1; add(filterInput, gbc); filterInput.getDocument().addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent e) { updateFiltering(); } @Override public void removeUpdate(DocumentEvent e) { updateFiltering(); } @Override public void changedUpdate(DocumentEvent e) { updateFiltering(); } }); } // Buttons gbc = makeGbc(2, 0, 1, 1); add(add, gbc); gbc = makeGbc(2, 1, 1, 1); add(remove, gbc); gbc = makeGbc(2, 2, 1, 1); add(edit, gbc); if (sortingMode == SORTING_MODE_MANUAL) { gbc = makeGbc(2, 3, 1, 1); add(moveUp, gbc); gbc = makeGbc(2, 4, 1, 1); add(moveDown, gbc); } if (refreshButton) { gbc = makeGbc(2, 5, 1, 1); add(refresh, gbc); } updateButtons(); } /** * Set the model for this table, which must be done before it is used. * * @param model */ protected final void setModel(ListTableModel<T> model) { data = model; table.setModel(model); if (sortingMode == SORTING_MODE_SORTED) { sorter = new TableRowSorter<>(model); table.setRowSorter(sorter); sorter.toggleSortOrder(0); } } /** * Allows to set custom renderers for certain classes. * * @param cellClass * @param renderer */ protected final void setDefaultRenderer(Class cellClass, TableCellRenderer renderer) { table.setDefaultRenderer(cellClass, renderer); } protected final void setRendererForColumn(int column, TableCellRenderer renderer) { table.getColumnModel().getColumn(column).setCellRenderer(renderer); } protected final void setFixedColumnWidth(int column, int size) { table.getColumnModel().getColumn(column).setMaxWidth(size); table.getColumnModel().getColumn(column).setMinWidth(size); } protected final void setColumnWidth(int column, int size) { table.getColumnModel().getColumn(column).setPreferredWidth(size); } /** * Set the data for this table. * * @param data */ public void setData(List<T> data) { this.data.setData(data); updateButtons(); } /** * Returns the (possibly edited by the user) data of this table. * * @return */ public List<T> getData() { return this.data.getData(); } /** * Sets the item editor, which must be done before stuff can be edited. * * @param editor */ public void setItemEditor(ItemEditor<T> editor) { this.editor = editor; } /** * Sets the context menu for this. * * @param menu */ public final void setPopupMenu(TableContextMenu<T> menu) { contextMenu = menu; } /** * Sets the {@code TableEditorListener}. Only one listener can be set at a * time. * * @param listener The listener to set */ public final void setTableEditorListener(TableEditorListener<T> listener) { this.listener = listener; } /** * Opens the context menu if this MouseEvent was a popup trigger and a menu * is set. * * @param e The MouseEvent */ private void popupMenu(MouseEvent e) { if (contextMenu != null && e.isPopupTrigger()) { int modelIndex = indexToModel(table.getSelectedRow()); if (modelIndex != -1) { T entry = data.get(modelIndex); contextMenu.showMenu(entry, table, e.getX(), e.getY()); } } } /** * Select the row at the given coordinates. * * @param p The {@code Point} containing the coordinates */ private void selectRowAt(Point p) { int row = table.rowAtPoint(p); if (row != -1) { setRowSelected(row); } } /** * Convenience method to create {@code GridBagConstraints}. * * @param x The x coordinate in the grid * @param y The y coordinate in the grid * @param w The width in the grid * @param h The height in the grid * @return {@code GridBagConstraints} with the given values */ private GridBagConstraints makeGbc(int x, int y, int w, int h) { GridBagConstraints gbc = new GridBagConstraints(); gbc.gridx = x; gbc.gridy = y; gbc.gridwidth = w; gbc.gridheight = h; return gbc; } private void updateFiltering() { String filterText = filterInput.getText(); RowFilter<ListTableModel<T>, Object> rf = null; try { rf = RowFilter.regexFilter(filterText, 0); } catch (PatternSyntaxException ex) { return; } currentlyFiltering = rf != null && !filterText.isEmpty(); sorter.setRowFilter(rf); scrollToSelection(); updateButtons(); } /** * Sets the size, icon and tooltip of a button and adds the ActionListener. * * @param button * @param icon * @param tooltip */ private void configureButton(JButton button, String icon, String tooltip) { button.setIcon(new ImageIcon(ListSelector.class.getResource(icon))); button.setToolTipText(tooltip); button.setPreferredSize(BUTTON_SIZE); button.setSize(BUTTON_SIZE); button.setMaximumSize(BUTTON_SIZE); button.setMinimumSize(BUTTON_SIZE); button.addActionListener(buttonActionListener); } /** * Update the enabled-state of the buttons. */ private void updateButtons() { boolean enabled = table.getSelectedRowCount() == 1; add.setEnabled(true); remove.setEnabled(enabled); edit.setEnabled(enabled); moveUp.setEnabled(enabled); moveDown.setEnabled(enabled); if (currentlyFiltering) { add.setEnabled(false); edit.setEnabled(false); moveUp.setEnabled(false); moveDown.setEnabled(false); } } /** * Sets the given row as selected and scrolls to it if necessary. * * @param viewIndex */ private void setRowSelected(int viewIndex) { table.getSelectionModel().setSelectionInterval(viewIndex, viewIndex); scrollToRow(viewIndex); } private void scrollToSelection() { int index = table.getSelectedRow(); scrollToRow(index); } private void scrollToRow(int index) { if (index != -1) { table.scrollRectToVisible(table.getCellRect(index, 0, true)); // System.out.println(table.getVisibleRect()+" "+); // Rectangle row = table.getCellRect(index, 0, true); // int visibleHeight = table.getVisibleRect().height; // int rowHeight = row.height; // if (visibleHeight > rowHeight*4) { // // } } } /** * Open the edit dialog with the given {@code preset} already filling in * the data it contains. If the edit dialog isn't canceled, the resulting * entry is added after checking for duplicates. It is added at the selected * position or at the beginning of the table if nothing is selected. * * @param preset The entry used to fill out some data in the edit dialog */ protected void addItem(T preset) { T result = editor.showEditor(preset, this, false); // If the user didn't cancel the dialog, work with the result. if (result != null) { // Check if the resulting entry is already in the table. if (data.contains(result)) { String[] options = new String[]{"Don't save", "Edit again"}; int r = JOptionPane.showOptionDialog(this, "Another item with the same name" + " is already in the list.", "Duplicate item", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, options, null); if (r == 1) { addItem(result); } } else { // Insert at the selected position or at the beginning of the // table if nothing is selected. int selectedIndex = table.getSelectedRow(); int modelIndex = indexToModel(selectedIndex); if (modelIndex != -1) { data.insert(modelIndex, result); setRowSelected(indexToView(modelIndex)); } else { data.insert(0, result); setRowSelected(indexToView(0)); } if (listener != null) { listener.itemAdded(result); } } } } /** * Edit the currently selected item. * * @see editItem(int modelIndex, T preset) */ private void editSelectedItem() { editItem(-1, null); } /** * Edit the entry at the given {@code modelIndex}. * * @param modelIndex The index * @see editItem(int modelIndex, T preset) */ protected void editItem(int modelIndex) { editItem(modelIndex, null); } /** * Open an edit dialog for the entry at the given {@code modelIndex}. * * @param modelIndex The model index. If this is -1 then the currently * selected entry is edited. If no entry is selected, then nothing is done. * @param preset The preset is used to fill out the dialog with the data it * contains, if it is {@code null}, then the edited entry is used as preset */ protected void editItem(int modelIndex, T preset) { if (modelIndex == -1) { modelIndex = indexToModel(table.getSelectedRow()); if (modelIndex == -1) { return; } } setRowSelected(indexToView(modelIndex)); if (preset == null) { preset = data.get(modelIndex); } T result = editor.showEditor(preset, this, true); // Done editing in the dialog, work with the result if the user didn't // cancel the dialog. if (result != null) { // Check if the resulting entry is already in the data, but is not // the one being edited, which means it would be a duplicate. int present = data.indexOf(result); if (present != -1 && present != modelIndex) { String[] options = new String[]{"Don't save", "Edit again"}; int r = JOptionPane.showOptionDialog(this, "Another item with the same name" + " is already in the list.", "Duplicate item", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, options, "Replace"); if (r == 1) { editItem(modelIndex, result); } } else { data.set(modelIndex, result); if (listener != null) { listener.itemEdited(preset, result); } } } setRowSelected(indexToView(modelIndex)); } /** * Remove the selected entry. If no entry is selected, nothing is done. * After removing, an appropriate remaining entry is selected. */ protected void removeSelected() { // If table is empty, nothing can be selected to remove if (table.getRowCount() == 0) { return; } // Get selected entry and remove it if present int viewIndex = table.getSelectedRow(); int modelIndex = indexToModel(viewIndex); if (modelIndex == -1) { return; } T removedItem = data.remove(modelIndex); // Select appropriate row after removing if (table.getRowCount() > viewIndex) { setRowSelected(viewIndex); } else if (viewIndex-1 >= 0 && table.getRowCount() > viewIndex-1) { setRowSelected(viewIndex-1); } // Update buttons state and inform listener updateButtons(); if (listener != null) { listener.itemRemoved(removedItem); } } /** * Moves the selected item up in the model (and table). This can behave * kind of odd when the table is filtered or sorted automatically, so it * should not be used then. */ protected void moveUpSelected() { int selectedIndex = table.getSelectedRow(); if (selectedIndex > -1) { int index = data.moveUp(indexToModel(selectedIndex)); setRowSelected(indexToView(index)); } } /** * Moves the selected item down in the model (and table). This can behave * kind of odd when the table is filtered or sorted automatically, so it * should not be used then. */ protected void moveDownSelected() { int selectedIndex = table.getSelectedRow(); if (selectedIndex > -1) { int index = data.moveDown(indexToModel(selectedIndex)); setRowSelected(indexToView(index)); } } /** * Convert a view index to model index. * * @param index The index to convert * @return The converted index, or {@code -1} if {@code index} was * {@code -1} */ private int indexToModel(int index) { if (index == -1) { return -1; } return table.convertRowIndexToModel(index); } /** * Convert a model index to view index. * * @param index The index to convert * @return The corresponding index of the view, or {@code -1} if the row * isn't visible */ private int indexToView(int index) { return table.convertRowIndexToView(index); } /** * Receives events from the buttons and calls the appropriate table methods. */ private class ButtonAction implements ActionListener { @Override public void actionPerformed(ActionEvent e) { if (e.getSource() == moveUp) { moveUpSelected(); } else if (e.getSource() == moveDown) { moveDownSelected(); } else if (e.getSource() == remove) { removeSelected(); } else if (e.getSource() == edit) { editSelectedItem(); } else if (e.getSource() == add) { addItem(null); } else if (e.getSource() == refresh) { if (listener != null) { listener.refreshData(); } } } } /** * A context menu that in addition to the invoker and coordinates, also * receives the item it was opened on, so it can build the menu accordingly. * * @param <T> The type of the item */ public static abstract class TableContextMenu<T> extends JPopupMenu { /** * The menu should open itself at the given coordinates. It can * customize itself based on which {@code item} it was opened for. * * @param item The item it was opened for (usually by right-clicking * on it) * @param invoker The Component it was opened on * @param x The x-coordinate where it should be opened * @param y The y-coordinate where it should be opened */ public abstract void showMenu(T item, Component invoker, int x, int y); } /** * An item editor is opened with the item to edit, the parent component * and whether the item is being edited or added. The implementation can * then build the GUI accordingly. When finished, the editor should give * the edited item back, or null if the action was canceled. * * @param <T> The type of the item to edit */ public static interface ItemEditor<T> { /** * Opens the editor, which the user can use to add or change an item. * * @param preset The item to fill the GUI with initially, can be * {@code null} * @param c The parent component * @param edit Whether this item is edited or added (might set the title * accordingly for example) * @return The changed or added item, or {@code null} if the action was * canceled */ public T showEditor(T preset, Component c, boolean edit); } /** * Users of the TableEditor can register a listener of this type to be * informed about edits to the table. This is one of the main ways to * actually change the data that is edited in this table elsewhere. * * @param <T> The type of the items to be edited */ public static interface TableEditorListener<T> { /** * Called when an item has been added to the table. The table should * not allow for duplicates to be added, but it is prudent to not rely * on that. * * @param item The item that was added */ public void itemAdded(T item); /** * Called when an item has been removed in the table. * * @param item The item that was removed */ public void itemRemoved(T item); /** * Called when an item was edited in the table. The {@code oldItem} * contains the item before editing, the {@code newItem} contains the * changed item, so it can also be determined what changed. * * @param oldItem The item before editing * @param newItem The item after editing */ public void itemEdited(T oldItem, T newItem); /** * Called when the user requested the data in the table to be refreshed. */ public void refreshData(); } private void search(char input) { // Reset search on backspace if (input == '\b') { resetSearch(); return; } // Reset search if searching on other column int column = data.getSearchColumn(table.getSelectedColumn()); if (column == -1) { // No search column, so don't do any search return; } if (column != searchColumn) { resetSearch(); } // Update state String pressed = String.valueOf(input); search += pressed.toLowerCase(); searchColumn = column; searchTime = System.currentTimeMillis(); //System.out.println("'" + search + "'"); // Rename header to current search table.getColumnModel().getColumn(column).setHeaderValue("[" + search + "]"); table.getTableHeader().repaint(); // Start timer to reset search if (!searchTimer.isRunning()) { searchTimer.start(); } // Perform search and select first result for (int i = 0; i < data.getRowCount(); i++) { String item = data.getSearchValueAt(i, column); if (item.toLowerCase().startsWith(search)) { setRowSelected(indexToView(i)); return; } if (item.toLowerCase().contains(search)) { setRowSelected(indexToView(i)); } } } private void checkResetSearch() { if (System.currentTimeMillis() - searchTime > 3000) { resetSearch(); } } private void resetSearch() { if (searchColumn != -1) { String originalValue = data.getColumnName(searchColumn); table.getColumnModel().getColumn(searchColumn).setHeaderValue(originalValue); table.getTableHeader().repaint(); } searchColumn = -1; search = ""; searchTimer.stop(); } }