package org.jabref.gui.search;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import javax.swing.AbstractAction;
import javax.swing.ActionMap;
import javax.swing.InputMap;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.table.TableColumnModel;
import org.jabref.Globals;
import org.jabref.gui.BasePanel;
import org.jabref.gui.GUIGlobals;
import org.jabref.gui.IconTheme;
import org.jabref.gui.JabRefFrame;
import org.jabref.gui.PreviewPanel;
import org.jabref.gui.TransferableBibtexEntry;
import org.jabref.gui.desktop.JabRefDesktop;
import org.jabref.gui.externalfiletype.ExternalFileMenuItem;
import org.jabref.gui.filelist.FileListEntry;
import org.jabref.gui.filelist.FileListTableModel;
import org.jabref.gui.keyboard.KeyBinding;
import org.jabref.gui.maintable.MainTableNameFormatter;
import org.jabref.gui.renderer.GeneralRenderer;
import org.jabref.gui.util.comparator.IconComparator;
import org.jabref.logic.bibtex.comparator.EntryComparator;
import org.jabref.logic.bibtex.comparator.FieldComparator;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.search.SearchQuery;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.FieldName;
import org.jabref.model.entry.FieldProperty;
import org.jabref.model.entry.InternalBibtexFields;
import org.jabref.model.strings.StringUtil;
import org.jabref.preferences.SearchPreferences;
import ca.odell.glazedlists.BasicEventList;
import ca.odell.glazedlists.EventList;
import ca.odell.glazedlists.SortedList;
import ca.odell.glazedlists.event.ListEvent;
import ca.odell.glazedlists.event.ListEventListener;
import ca.odell.glazedlists.gui.AbstractTableComparatorChooser;
import ca.odell.glazedlists.gui.AdvancedTableFormat;
import ca.odell.glazedlists.swing.DefaultEventSelectionModel;
import ca.odell.glazedlists.swing.DefaultEventTableModel;
import ca.odell.glazedlists.swing.GlazedListsSwing;
import ca.odell.glazedlists.swing.TableComparatorChooser;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
* Dialog to display search results, potentially from more than one BasePanel, with
* possibility to preview and to locate each entry in the main window.
*/
public class SearchResultFrame {
private static final String[] FIELDS = new String[] {
FieldName.AUTHOR, FieldName.TITLE, FieldName.YEAR, FieldName.JOURNAL
};
private static final int DATABASE_COL = 0;
private static final int FILE_COL = 1;
private static final int URL_COL = 2;
private static final int PAD = 3;
private static final Log LOGGER = LogFactory.getLog(SearchResultFrame.class);
private final JabRefFrame frame;
private JFrame searchResultFrame;
private final JLabel fileLabel = new JLabel(IconTheme.JabRefIcon.FILE.getSmallIcon());
private final JLabel urlLabel = new JLabel(IconTheme.JabRefIcon.WWW.getSmallIcon());
private final JSplitPane contentPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
private final Rectangle toRect = new Rectangle(0, 0, 1, 1);
private final EventList<BibEntry> entries = new BasicEventList<>();
private final Map<BibEntry, BasePanel> entryHome = new HashMap<>();
private DefaultEventTableModel<BibEntry> model;
private SortedList<BibEntry> sortedEntries;
private JTable entryTable;
private PreviewPanel preview;
private SearchQuery searchQuery;
private boolean globalSearch;
public SearchResultFrame(JabRefFrame frame, String title, SearchQuery searchQuery, boolean globalSearch) {
this.frame = Objects.requireNonNull(frame);
this.searchQuery = searchQuery;
this.globalSearch = globalSearch;
frame.getGlobalSearchBar().setSearchResultFrame(this);
init(Objects.requireNonNull(title));
}
private void init(String title) {
searchResultFrame = new JFrame();
searchResultFrame.setTitle(title);
searchResultFrame.setIconImages(IconTheme.getLogoSet());
preview = new PreviewPanel(null, null);
sortedEntries = new SortedList<>(entries, new EntryComparator(false, true, FieldName.AUTHOR));
model = (DefaultEventTableModel<BibEntry>) GlazedListsSwing.eventTableModelWithThreadProxyList(sortedEntries,
new EntryTableFormat());
entryTable = new JTable(model);
GeneralRenderer renderer = new GeneralRenderer(Color.white);
entryTable.setDefaultRenderer(JLabel.class, renderer);
entryTable.setDefaultRenderer(String.class, renderer);
setWidths();
TableComparatorChooser<BibEntry> tableSorter =
TableComparatorChooser.install(entryTable, sortedEntries,
AbstractTableComparatorChooser.MULTIPLE_COLUMN_KEYBOARD);
setupComparatorChooser(tableSorter);
JScrollPane sp = new JScrollPane(entryTable);
final DefaultEventSelectionModel<BibEntry> selectionModel = (DefaultEventSelectionModel<BibEntry>) GlazedListsSwing
.eventSelectionModelWithThreadProxyList(sortedEntries);
entryTable.setSelectionModel(selectionModel);
selectionModel.getSelected().addListEventListener(new EntrySelectionListener());
entryTable.addMouseListener(new TableClickListener());
contentPane.setTopComponent(sp);
contentPane.setBottomComponent(preview);
// Key bindings:
AbstractAction closeAction = new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
dispose();
}
};
ActionMap actionMap = contentPane.getActionMap();
InputMap inputMap = contentPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW);
inputMap.put(Globals.getKeyPrefs().getKey(KeyBinding.CLOSE_DIALOG), "close");
inputMap.put(Globals.getKeyPrefs().getKey(KeyBinding.CLOSE_DATABASE), "close");
actionMap.put("close", closeAction);
actionMap = entryTable.getActionMap();
inputMap = entryTable.getInputMap();
//Override 'selectNextColumnCell' and 'selectPreviousColumnCell' to move rows instead of cells on TAB
actionMap.put("selectNextColumnCell", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
selectNextEntry();
}
});
actionMap.put("selectPreviousColumnCell", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
selectPreviousEntry();
}
});
actionMap.put("selectNextRow", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
selectNextEntry();
}
});
actionMap.put("selectPreviousRow", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
selectPreviousEntry();
}
});
String selectFirst = "selectFirst";
inputMap.put(Globals.getKeyPrefs().getKey(KeyBinding.SELECT_FIRST_ENTRY), selectFirst);
actionMap.put(selectFirst, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
selectFirstEntry();
}
});
String selectLast = "selectLast";
inputMap.put(Globals.getKeyPrefs().getKey(KeyBinding.SELECT_LAST_ENTRY), selectLast);
actionMap.put(selectLast, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
selectLastEntry();
}
});
actionMap.put("copy", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (!selectionModel.getSelected().isEmpty()) {
List<BibEntry> bes = selectionModel.getSelected();
TransferableBibtexEntry trbe = new TransferableBibtexEntry(bes);
// ! look at ClipBoardManager
Toolkit.getDefaultToolkit().getSystemClipboard()
.setContents(trbe, frame.getCurrentBasePanel());
frame.output(Localization.lang("Copied") + ' ' + (bes.size() > 1 ? bes.size() + " "
+ Localization.lang("entries")
: "1 " + Localization.lang("entry") + '.'));
}
}
});
// override standard enter-action; enter opens the selected entry
entryTable.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "Enter");
actionMap.put("Enter", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent ae) {
BibEntry entry = sortedEntries.get(entryTable.getSelectedRow());
selectEntryInBasePanel(entry);
}
});
searchResultFrame.addWindowListener(new WindowAdapter() {
@Override
public void windowOpened(WindowEvent e) {
contentPane.setDividerLocation(0.5f);
}
@Override
public void windowClosing(WindowEvent event) {
dispose();
}
});
searchResultFrame.getContentPane().add(contentPane, BorderLayout.CENTER);
// Remember and default to last size:
SearchPreferences searchPreferences = new SearchPreferences(Globals.prefs);
searchResultFrame.setSize(searchPreferences.getSeachDialogWidth(), searchPreferences.getSeachDialogHeight());
searchResultFrame.setLocation(searchPreferences.getSearchDialogPosX(), searchPreferences.getSearchDialogPosY());
searchResultFrame.addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
new SearchPreferences(Globals.prefs)
.setSearchDialogWidth(searchResultFrame.getSize().width)
.setSearchDialogHeight(searchResultFrame.getSize().height);
}
@Override
public void componentMoved(ComponentEvent e) {
new SearchPreferences(Globals.prefs)
.setSearchDialogPosX(searchResultFrame.getLocation().x)
.setSearchDialogPosY(searchResultFrame.getLocation().y);
}
});
}
/**
* Control the visibility of the dialog.
* @param visible true to show dialog, false to hide.
*/
public void setVisible(boolean visible) {
searchResultFrame.setVisible(visible);
}
public void selectFirstEntry() {
selectEntry(0);
}
public void selectLastEntry() {
selectEntry(entryTable.getRowCount() - 1);
}
public void selectPreviousEntry() {
selectEntry((entryTable.getSelectedRow() - 1 + entryTable.getRowCount()) % entryTable.getRowCount());
}
public void selectNextEntry() {
selectEntry((entryTable.getSelectedRow() + 1) % entryTable.getRowCount());
}
public void selectEntry(int index) {
if (index >= 0 && index < entryTable.getRowCount()) {
entryTable.changeSelection(index, 0, false, false);
} else {
contentPane.setDividerLocation(1.0f);
}
}
/**
* Set up the comparators for each column, so the user can modify sort order
* by clicking the column labels.
* @param comparatorChooser The comparator chooser controlling the sort order.
*/
private void setupComparatorChooser(TableComparatorChooser<BibEntry> comparatorChooser) {
List<Comparator> comparators;
// Icon columns:
for (int i = 0; i < PAD; i++) {
comparators = comparatorChooser.getComparatorsForColumn(i);
comparators.clear();
if (i == FILE_COL) {
comparators.add(new IconComparator(Collections.singletonList(FieldName.FILE)));
} else if (i == URL_COL) {
comparators.add(new IconComparator(Collections.singletonList(FieldName.URL)));
} else if (i == DATABASE_COL) {
comparators.add((entry1, entry2) -> {
String databaseTitle1 = entryHome.get(entry1).getTabTitle();
String databaseTitle2 = entryHome.get(entry2).getTabTitle();
return databaseTitle1.compareTo(databaseTitle2);
});
}
}
// Remaining columns:
for (int i = PAD; i < (PAD + FIELDS.length); i++) {
comparators = comparatorChooser.getComparatorsForColumn(i);
comparators.clear();
comparators.add(new FieldComparator(FIELDS[i - PAD]));
}
sortedEntries.getReadWriteLock().writeLock().lock();
comparatorChooser.appendComparator(PAD, 0, false);
sortedEntries.getReadWriteLock().writeLock().unlock();
}
/**
* Set column widths according to which field is shown, and lock icon columns
* to a suitable width.
*/
private void setWidths() {
TableColumnModel cm = entryTable.getColumnModel();
for (int i = 0; i < PAD + FIELDS.length; i++) {
switch (i) {
case FILE_COL:
case URL_COL:
cm.getColumn(i).setPreferredWidth(GUIGlobals.WIDTH_ICON_COL);
cm.getColumn(i).setMinWidth(GUIGlobals.WIDTH_ICON_COL);
cm.getColumn(i).setMaxWidth(GUIGlobals.WIDTH_ICON_COL);
break;
case DATABASE_COL: {
int width = InternalBibtexFields.getFieldLength(FieldName.AUTHOR);
cm.getColumn(i).setPreferredWidth(width);
break;
}
default: {
int width = InternalBibtexFields.getFieldLength(FIELDS[i - PAD]);
cm.getColumn(i).setPreferredWidth(width);
break;
}
}
}
}
/**
* Add a list of entries to the table.
* @param newEntries The list of entries.
* @param panel A reference to the BasePanel where the entries belong.
*/
public void addEntries(List<BibEntry> newEntries, BasePanel panel) {
for (BibEntry entry : newEntries) {
addEntry(entry, panel);
}
}
/**
* Add a single entry to the table.
* @param entry The entry to add.
* @param panel A reference to the BasePanel where the entry belongs.
*/
private void addEntry(BibEntry entry, BasePanel panel) {
entries.add(entry);
entryHome.put(entry, panel);
if (preview.getEntry() == null || !preview.getBasePanel().isPresent()) {
preview.setEntry(entry);
preview.setBasePanel(panel);
preview.setDatabaseContext(panel.getBibDatabaseContext());
}
}
private void selectEntryInBasePanel(BibEntry entry) {
BasePanel basePanel = entryHome.get(entry);
frame.showBasePanel(basePanel);
basePanel.requestFocus();
basePanel.highlightEntry(entry);
}
public void dispose() {
frame.getGlobalSearchBar().setSearchResultFrame(null);
searchResultFrame.dispose();
frame.getGlobalSearchBar().focus();
}
public void focus() {
entryTable.requestFocus();
}
public SearchQuery getSearchQuery() {
return searchQuery;
}
public boolean isGlobalSearch() {
return globalSearch;
}
/**
* Mouse listener for the entry table. Processes icon clicks to open external
* files or urls, as well as the opening of the context menu.
*/
class TableClickListener extends MouseAdapter {
@Override
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger()) {
processPopupTrigger(e);
}
}
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger()) {
processPopupTrigger(e);
return;
}
// First find the row on which the user has clicked.
final int row = entryTable.rowAtPoint(e.getPoint());
// A double click on an entry should highlight the entry in its BasePanel:
if (e.getClickCount() == 2) {
selectEntryInBasePanel(model.getElementAt(row));
}
}
@Override
public void mouseClicked(MouseEvent e) {
if (e.isPopupTrigger()) {
processPopupTrigger(e);
return;
}
//if (e.)
final int col = entryTable.columnAtPoint(e.getPoint());
final int row = entryTable.rowAtPoint(e.getPoint());
if (col < PAD) {
BibEntry entry = sortedEntries.get(row);
BasePanel p = entryHome.get(entry);
switch (col) {
case FILE_COL:
if (entry.hasField(FieldName.FILE)) {
FileListTableModel tableModel = new FileListTableModel();
entry.getField(FieldName.FILE).ifPresent(tableModel::setContent);
if (tableModel.getRowCount() == 0) {
return;
}
FileListEntry fl = tableModel.getEntry(0);
(new ExternalFileMenuItem(frame, entry, "", fl.getLink(), null,
p.getBibDatabaseContext(), fl.getType())).actionPerformed(null);
}
break;
case URL_COL:
entry.getField(FieldName.URL).ifPresent(link -> { try {
JabRefDesktop.openExternalViewer(p.getBibDatabaseContext(), link, FieldName.URL);
} catch (IOException ex) {
LOGGER.warn("Could not open viewer", ex);
}
});
break;
default:
break;
}
}
}
/**
* If the user has signalled the opening of a context menu, the event
* gets redirected to this method. Here we open a file link menu if the
* user is pointing at a file link icon. Otherwise a general context
* menu should be shown.
* @param e The triggering mouse event.
*/
public void processPopupTrigger(MouseEvent e) {
BibEntry entry = sortedEntries.get(entryTable.rowAtPoint(e.getPoint()));
BasePanel p = entryHome.get(entry);
int col = entryTable.columnAtPoint(e.getPoint());
JPopupMenu menu = new JPopupMenu();
int count = 0;
if (col == FILE_COL) {
// We use a FileListTableModel to parse the field content:
FileListTableModel fileList = new FileListTableModel();
entry.getField(FieldName.FILE).ifPresent(fileList::setContent);
// If there are one or more links, open the first one:
for (int i = 0; i < fileList.getRowCount(); i++) {
FileListEntry flEntry = fileList.getEntry(i);
String description = flEntry.getDescription();
if ((description == null) || (description.trim().isEmpty())) {
description = flEntry.getLink();
}
menu.add(new ExternalFileMenuItem(p.frame(), entry, description, flEntry.getLink(),
flEntry.getType().get().getIcon(), p.getBibDatabaseContext(), flEntry.getType()));
count++;
}
}
if (count > 0) {
menu.show(entryTable, e.getX(), e.getY());
}
}
}
/**
* The listener for the Glazed list monitoring the current selection.
* When selection changes, we need to update the preview panel.
*/
private class EntrySelectionListener implements ListEventListener<BibEntry> {
@Override
public void listChanged(ListEvent<BibEntry> listEvent) {
if (listEvent.getSourceList().size() == 1) {
BibEntry entry = listEvent.getSourceList().get(0);
// Find out which BasePanel the selected entry belongs to:
BasePanel basePanel = entryHome.get(entry);
// Update the preview's database context:
preview.setDatabaseContext(basePanel.getBibDatabaseContext());
// Update the preview's entry:
preview.setEntry(entry);
preview.setBasePanel(entryHome.get(entry));
preview.setDatabaseContext(entryHome.get(entry).getBibDatabaseContext());
contentPane.setDividerLocation(0.5f);
SwingUtilities.invokeLater(() -> preview.scrollRectToVisible(toRect));
}
}
}
/**
* TableFormat for the table shown in the dialog. Handles the display of entry
* fields and icons for linked files and urls.
*/
private class EntryTableFormat implements AdvancedTableFormat<BibEntry> {
@Override
public int getColumnCount() {
return PAD + FIELDS.length;
}
@Override
public String getColumnName(int column) {
if (column >= PAD) {
return StringUtil.capitalizeFirst(FIELDS[column - PAD]);
} else if (column == DATABASE_COL) {
return Localization.lang("Library");
} else {
return "";
}
}
@Override
public Object getColumnValue(BibEntry entry, int column) {
if (column < PAD) {
switch (column) {
case DATABASE_COL:
return entryHome.get(entry).getTabTitle();
case FILE_COL:
if (entry.hasField(FieldName.FILE)) {
FileListTableModel tmpModel = new FileListTableModel();
entry.getField(FieldName.FILE).ifPresent(tmpModel::setContent);
fileLabel.setToolTipText(tmpModel.getToolTipHTMLRepresentation());
if (tmpModel.getRowCount() > 0) {
if (tmpModel.getEntry(0).getType().isPresent()) {
fileLabel.setIcon(tmpModel.getEntry(0).getType().get().getIcon());
} else {
fileLabel.setIcon(IconTheme.JabRefIcon.FILE.getSmallIcon());
}
}
return fileLabel;
} else {
return null;
}
case URL_COL: {
Optional<String> urlField = entry.getField(FieldName.URL);
if (urlField.isPresent()) {
urlLabel.setToolTipText(urlField.get());
return urlLabel;
}
return null;
}
default:
return null;
}
}
else {
String field = FIELDS[column - PAD];
String fieldContent = entry.getLatexFreeField(field).orElse("");
if (InternalBibtexFields.getFieldProperties(field).contains(FieldProperty.PERSON_NAMES)) {
// For name fields, tap into a MainTableFormat instance and use
// the same name formatting as is used in the entry table:
return MainTableNameFormatter.formatName(fieldContent);
}
return fieldContent;
}
}
@Override
public Class<?> getColumnClass(int i) {
switch (i) {
case FILE_COL:
case URL_COL:
return JLabel.class;
default:
return String.class;
}
}
@Override
public Comparator<?> getColumnComparator(int i) {
return null;
}
}
}