/* * Copyright 2011 Luke Usherwood. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package net.bettyluke.tracinstant.ui; import java.awt.event.ActionEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.TreeMap; import java.util.regex.Pattern; import javax.swing.AbstractAction; import javax.swing.JPopupMenu; import javax.swing.JTable; import javax.swing.SwingUtilities; import javax.swing.event.ChangeEvent; import javax.swing.event.ListSelectionEvent; import javax.swing.event.TableColumnModelEvent; import javax.swing.event.TableColumnModelListener; import javax.swing.table.JTableHeader; import javax.swing.table.TableColumnModel; import javax.swing.table.TableRowSorter; import net.bettyluke.tracinstant.data.Ticket; import net.bettyluke.tracinstant.data.TicketTableModel; import net.bettyluke.tracinstant.prefs.TracInstantProperties; import net.bettyluke.tracinstant.ui.MenuCascader.Item; public class TicketTable extends JTable { public static class ColumnWidthMemoriser { private static final String COLUMNS_KEY = "TicketTableColumns"; /** * Basic defaults so that a few specific columns appear together on the left, * prior to being user-customised. TODO: Numbers are currently based on the * HACK (commented lower in the file) and will need revising. */ private static final String COLUMNS_DEFAULT = "[#=65, summary=350, reporter=100]"; private final JTableHeader tableHeader; public ColumnWidthMemoriser(JTableHeader header) { tableHeader = header; } public void attach() { tableHeader.addMouseListener(new MouseAdapter() { @Override public void mouseReleased(MouseEvent e) { memoriseColumnLayout(); } }); tableHeader.getColumnModel().addColumnModelListener(new TableColumnModelListener() { @Override public void columnSelectionChanged(ListSelectionEvent e) { } @Override public void columnRemoved(TableColumnModelEvent e) { } @Override public void columnMoved(TableColumnModelEvent e) { } @Override public void columnMarginChanged(ChangeEvent e) { } @Override public void columnAdded(TableColumnModelEvent e) { SwingUtilities.invokeLater(() -> recallColumnLayout()); } }); } protected void memoriseColumnLayout() { TableColumnModel cm = tableHeader.getColumnModel(); List<String> headerWidths = new ArrayList<>(); for (int i = 0; i < cm.getColumnCount(); ++i) { headerWidths.add( cm.getColumn(i).getHeaderValue() + "=" + cm.getColumn(i).getWidth()); } TracInstantProperties.putStringList(COLUMNS_KEY, headerWidths); } protected void recallColumnLayout() { List<String> stored = TracInstantProperties.getStringList( COLUMNS_KEY, COLUMNS_DEFAULT); Map<String, Integer> widths = new LinkedHashMap<>(stored.size()); try { for (String column : stored) { String[] s = column.split("\\="); if (s.length != 2) { System.err.println("Invalid TicketTableColumns property"); continue; } widths.put(s[0], Integer.valueOf(s[1])); } } catch (NumberFormatException ex) { ex.printStackTrace(); } TableColumnModel cm = tableHeader.getColumnModel(); int newIndex = 0; for (String colName : widths.keySet()) { int oldIndex = findColumnIndex(cm, newIndex, colName); if (oldIndex > -1) { cm.moveColumn(oldIndex, newIndex); // HACK! only a temporary sizing measure. // TODO: Proportion correctly to the total size of the header. // (then fix basic defaults!) cm.getColumn(newIndex).setPreferredWidth(widths.get(colName)); newIndex++; } } } private int findColumnIndex(TableColumnModel cm, int from, String colName) { for (int i = 0; i < cm.getColumnCount(); i++) { if (colName.equals(cm.getColumn(i).getHeaderValue())) { return i; } } return -1; } } /** * We simply store a reference to the main-frame's text component to update it directly. * (Perhaps it would be cleaner to access it via an interface.) */ private final SearchCombo searchCombo; public TicketTable(TicketTableModel model, SearchCombo searchCombo) { super(model); this.searchCombo = searchCombo; setRowSorter(new TableRowSorter<>(model)); addColumnContextMenu(); ColumnWidthMemoriser cwm = new ColumnWidthMemoriser(getTableHeader()); cwm.attach(); } protected void addColumnContextMenu() { getTableHeader().addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (!SwingUtilities.isRightMouseButton(e)) { return; } if (e.getClickCount() == 1) { int viewColumn = getColumnModel().getColumnIndexAtX(e.getX()); final JPopupMenu menu = createColumnFilterMenu(viewColumn); menu.show(getTableHeader(), e.getX(), e.getY()); } } }); } private JPopupMenu createColumnFilterMenu(int viewColumn) { String colName = getColumnName(viewColumn); Map<String, Integer> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); for (int r = 0, end = getRowCount(); r < end; ++r) { Object value = getValueAt(r, viewColumn); String[] strs = value.toString().split("[\\s\\,]+"); for (String s : strs) { Integer count = map.get(s); map.put(s, (count == null) ? 1 : count + 1); } } List<Item> items = new ArrayList<>(); for (Entry<String, Integer> s : map.entrySet()) { items.add(new FilterColumnAction(colName, s.getKey(), s.getValue())); } return new MenuCascader().create(items); } /** An action that adds a new search-term to m_FilterText when invoked. */ public class FilterColumnAction extends AbstractAction implements MenuCascader.Item { private final String columnName; private final String item; private final int hitCount; public FilterColumnAction(String columnName, String item, int hitCount) { super(item + " (" + hitCount + ")"); this.columnName = columnName; this.item = item; this.hitCount = hitCount; } @Override public void actionPerformed(ActionEvent e) { String text = searchCombo.getEditorText(); StringBuilder sb = new StringBuilder(text); sb.append(' ').append(columnName).append(':'); sb.append(addWordBoundaries(escape(item))); searchCombo.setEditorText(sb.toString().trim()); searchCombo.requestFocusInWindow(); } private String escape(String text) { return text.replaceAll("([^0-9a-zA-Z_])", "\\\\$1"); } private String addWordBoundaries(String text) { if (text.isEmpty()) { return text; } Pattern startPattern = Pattern.compile(Pattern.quote(text) + "\\B", Pattern.CASE_INSENSITIVE); Pattern endPattern = Pattern.compile("\\B" + Pattern.quote(text), Pattern.CASE_INSENSITIVE); int nTickets = getModel().getRowCount(); boolean startMatch = false; boolean endMatch = false; for (int i = 0; i < nTickets; ++i) { Ticket ticket = getModel().getTicket(i); String value = ticket.getValue(columnName); startMatch |= startPattern.matcher(value).find(); endMatch |= endPattern.matcher(value).find(); } return (endMatch ? "\\b" : "") + text + (startMatch ? "\\b" : ""); } @Override public String getName() { return item; } @Override public int getHits() { return hitCount; } } @Override public TicketTableModel getModel() { return (TicketTableModel) super.getModel(); } @SuppressWarnings("unchecked") @Override public TableRowSorter<? extends TicketTableModel> getRowSorter() { return (TableRowSorter<? extends TicketTableModel>) super.getRowSorter(); } public Ticket[] getSelectedTickets() { TicketTableModel model = getModel(); Ticket[] result = new Ticket[getSelectedRowCount()]; int i = 0; for (int row : getSelectedRows()) { result[i++] = model.getTicket(convertRowIndexToModel(row)); } return result; } }