package org.jabref.gui.maintable; import java.awt.event.FocusEvent; import java.awt.event.FocusListener; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; import java.io.IOException; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Optional; import javax.swing.Icon; import javax.swing.JLabel; import javax.swing.JPopupMenu; import javax.swing.SwingUtilities; import javax.swing.Timer; import org.jabref.Globals; import org.jabref.JabRefExecutorService; import org.jabref.JabRefGUI; import org.jabref.gui.BasePanel; import org.jabref.gui.BasePanelMode; import org.jabref.gui.GUIGlobals; import org.jabref.gui.IconTheme; import org.jabref.gui.PreviewPanel; import org.jabref.gui.actions.CopyDoiUrlAction; import org.jabref.gui.desktop.JabRefDesktop; import org.jabref.gui.entryeditor.EntryEditor; import org.jabref.gui.externalfiletype.ExternalFileMenuItem; import org.jabref.gui.externalfiletype.ExternalFileType; import org.jabref.gui.filelist.FileListEntry; import org.jabref.gui.filelist.FileListTableModel; import org.jabref.gui.menus.RightClickMenu; import org.jabref.gui.specialfields.SpecialFieldMenuAction; import org.jabref.gui.specialfields.SpecialFieldValueViewModel; import org.jabref.gui.specialfields.SpecialFieldViewModel; import org.jabref.logic.l10n.Localization; import org.jabref.logic.util.OS; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.FieldName; import org.jabref.model.entry.specialfields.SpecialField; import org.jabref.model.entry.specialfields.SpecialFieldValue; import org.jabref.preferences.PreviewPreferences; import ca.odell.glazedlists.EventList; import ca.odell.glazedlists.event.ListEvent; import ca.odell.glazedlists.event.ListEventListener; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; /** * List event, mouse, key and focus listener for the main table that makes up the * most part of the BasePanel for a single BIB database. */ public class MainTableSelectionListener implements ListEventListener<BibEntry>, MouseListener, KeyListener, FocusListener { private static final Log LOGGER = LogFactory.getLog(MainTableSelectionListener.class); private final MainTable table; private final BasePanel panel; private final EventList<BibEntry> tableRows; private PreviewPanel preview; private boolean previewActive = Globals.prefs.getPreviewPreferences().isPreviewPanelEnabled(); private boolean workingOnPreview; private boolean enabled = true; // Register the last character pressed to quick jump in the table. Together // with storing the last row number jumped to, this is used to let multiple // key strokes cycle between all entries starting with the same letter: private final int[] lastPressed = new int[20]; private int lastPressedCount; private long lastPressedTime; public MainTableSelectionListener(BasePanel panel, MainTable table) { this.table = table; this.panel = panel; this.tableRows = table.getTableModel().getTableRows(); PreviewPanel previewPanel = panel.getPreviewPanel(); if (previewPanel != null) { preview = previewPanel; } else { preview = new PreviewPanel(panel.getBibDatabaseContext(), null, panel); panel.frame().getGlobalSearchBar().getSearchQueryHighlightObservable().addSearchListener(preview); } } public void setEnabled(boolean enabled) { this.enabled = enabled; } @Override public void listChanged(ListEvent<BibEntry> e) { if (!enabled) { return; } EventList<BibEntry> selected = e.getSourceList(); if (selected.isEmpty()) { return; } final BibEntry newSelected = selected.get(0); if ((panel.getMode() == BasePanelMode.SHOWING_EDITOR || panel.getMode() == BasePanelMode.WILL_SHOW_EDITOR) && panel.getCurrentEditor() != null && newSelected == panel.getCurrentEditor().getEntry()) { // entry already selected and currently editing it, do not steal the focus from the selected textfield return; } if (newSelected != null) { final BasePanelMode mode = panel.getMode(); // What is the panel already showing? if ((mode == BasePanelMode.WILL_SHOW_EDITOR) || (mode == BasePanelMode.SHOWING_EDITOR)) { // An entry is currently being edited. EntryEditor oldEditor = panel.getCurrentEditor(); String visName = null; if (oldEditor != null) { visName = oldEditor.getVisiblePanelName(); } // Get a new editor for the entry to edit: EntryEditor newEditor = panel.getEntryEditor(newSelected); // Show the new editor unless it was already visible: if (!Objects.equals(newEditor, oldEditor) || (mode != BasePanelMode.SHOWING_EDITOR)) { if (visName != null) { newEditor.setVisiblePanel(visName); } panel.showEntryEditor(newEditor); SwingUtilities.invokeLater(() -> table.ensureVisible(table.getSelectedRow())); } else { // if not used destroy the EntryEditor newEditor.setMovingToDifferentEntry(); } } else { // Either nothing or a preview was shown. Update the preview. if (previewActive) { updatePreview(newSelected, false); } } } } private void updatePreview(final BibEntry toShow, final boolean changedPreview) { updatePreview(toShow, changedPreview, 0); } private void updatePreview(final BibEntry toShow, final boolean changedPreview, int repeats) { if (workingOnPreview) { if (repeats > 0) { return; // We've already waited once. Give up on this selection. } Timer t = new Timer(50, actionEvent -> updatePreview(toShow, changedPreview, 1)); t.setRepeats(false); t.start(); return; } EventList<BibEntry> list = table.getSelected(); // Check if the entry to preview is still selected: if ((list.size() != 1) || (list.get(0) != toShow)) { return; } final BasePanelMode mode = panel.getMode(); workingOnPreview = true; SwingUtilities.invokeLater(() -> { preview.setEntry(toShow); // If nothing was already shown, set the preview and move the separator: if (changedPreview || (mode == BasePanelMode.SHOWING_NOTHING)) { panel.showPreview(preview); panel.adjustSplitter(); } workingOnPreview = false; }); } public void editSignalled() { if (table.getSelected().size() == 1) { editSignalled(table.getSelected().get(0)); } } public void editSignalled(BibEntry entry) { final BasePanelMode mode = panel.getMode(); if (mode != BasePanelMode.SHOWING_EDITOR) { panel.showEntryEditor(panel.getEntryEditor(entry)); } panel.getCurrentEditor().requestFocus(); } @Override public void mouseReleased(MouseEvent e) { // First find the column and row on which the user has clicked. final int col = table.columnAtPoint(e.getPoint()); final int row = table.rowAtPoint(e.getPoint()); // get the MainTableColumn which is currently visible at col int modelIndex = table.getColumnModel().getColumn(col).getModelIndex(); MainTableColumn modelColumn = table.getMainTableColumn(modelIndex); // Check if the user has right-clicked. If so, open the right-click menu. if (e.isPopupTrigger() || (e.getButton() == MouseEvent.BUTTON3)) { if ((modelColumn == null) || !modelColumn.isIconColumn()) { // show normal right click menu processPopupTrigger(e, row); } else { // show right click menu for icon columns showIconRightClickMenu(e, row, modelColumn); } } } @Override public void mousePressed(MouseEvent e) { // all handling is done in "mouseReleased" } @Override public void mouseClicked(MouseEvent e) { // First find the column on which the user has clicked. final int row = table.rowAtPoint(e.getPoint()); // A double click on an entry should open the entry's editor. if (e.getClickCount() == 2) { BibEntry toShow = tableRows.get(row); editSignalled(toShow); return; } final int col = table.columnAtPoint(e.getPoint()); // get the MainTableColumn which is currently visible at col int modelIndex = table.getColumnModel().getColumn(col).getModelIndex(); MainTableColumn modelColumn = table.getMainTableColumn(modelIndex); // Workaround for Windows. Right-click is not popup trigger on mousePressed, but // on mouseReleased. Therefore we need to avoid taking action at this point, because // action will be taken when the button is released: if (OS.WINDOWS && (modelColumn.isIconColumn()) && (e.getButton() != MouseEvent.BUTTON1)) { return; } // Check if the clicked colum is a specialfield column if (modelColumn.isIconColumn() && (SpecialField.isSpecialField(modelColumn.getColumnName()))) { // handle specialfield handleSpecialFieldLeftClick(e, modelColumn.getColumnName()); } else if (modelColumn.isIconColumn()) { // left click on icon field Object value = table.getValueAt(row, col); if (value == null) { return; // No icon here, so we do nothing. } final BibEntry entry = tableRows.get(row); final List<String> fieldNames = modelColumn.getBibtexFields(); // Open it now. We do this in a thread, so the program won't freeze during the wait. JabRefExecutorService.INSTANCE.execute(() -> { panel.output(Localization.lang("External viewer called") + '.'); // check for all field names whether a link is present // (is relevant for combinations such as "url/doi") for (String fieldName : fieldNames) { // Check if field is present, if not skip this field if (entry.hasField(fieldName)) { String link = entry.getField(fieldName).get(); // See if this is a simple file link field, or if it is a file-list // field that can specify a list of links: if (fieldName.equals(FieldName.FILE)) { // We use a FileListTableModel to parse the field content: FileListTableModel fileList = new FileListTableModel(); fileList.setContent(link); FileListEntry flEntry = null; // If there are one or more links of the correct type, open the first one: if (modelColumn.isFileFilter()) { for (int i = 0; i < fileList.getRowCount(); i++) { if (fileList.getEntry(i).getType().toString().equals(modelColumn.getColumnName())) { flEntry = fileList.getEntry(i); break; } } } else if (fileList.getRowCount() > 0) { //If there are no file types specified open the first file flEntry = fileList.getEntry(0); } if (flEntry != null) { ExternalFileMenuItem item = new ExternalFileMenuItem(panel.frame(), entry, "", flEntry.getLink(), flEntry.getType().map(ExternalFileType::getIcon).orElse(null), panel.getBibDatabaseContext(), flEntry.getType()); item.doClick(); } } else { try { JabRefDesktop.openExternalViewer(panel.getBibDatabaseContext(), link, fieldName); } catch (IOException ex) { panel.output(Localization.lang("Unable to open link.")); LOGGER.info("Unable to open link", ex); } } break; // only open the first link } } }); } else if (modelColumn.getBibtexFields().contains(FieldName.CROSSREF)) { // Clicking on crossref column tableRows.get(row).getField(FieldName.CROSSREF) .ifPresent(crossref -> panel.getDatabase().getEntryByKey(crossref).ifPresent(entry -> panel.highlightEntry(entry))); } panel.frame().updateEnabledState(); } /** * Method to handle a single left click on one the special fields (e.g., ranking, quality, ...) * Shows either a popup to select/clear a value or simply toggles the functionality to set/unset the special field * * @param e MouseEvent used to determine the position of the popups * @param columnName the name of the specialfield column */ private void handleSpecialFieldLeftClick(MouseEvent e, String columnName) { if ((e.getClickCount() == 1)) { SpecialField.getSpecialFieldInstanceFromFieldName(columnName).ifPresent(field -> { // special field found if (field.isSingleValueField()) { // directly execute toggle action instead of showing a menu with one action new SpecialFieldViewModel(field).getSpecialFieldAction(field.getValues().get(0), panel.frame()).action(); } else { JPopupMenu menu = new JPopupMenu(); for (SpecialFieldValue val : field.getValues()) { menu.add(new SpecialFieldMenuAction(new SpecialFieldValueViewModel(val), panel.frame())); } menu.show(table, e.getX(), e.getY()); } }); } } /** * Process general right-click events on the table. Show the table context menu at * the position where the user right-clicked. * @param e The mouse event defining the popup trigger. * @param row The row where the event occurred. */ private void processPopupTrigger(MouseEvent e, int row) { int selRow = table.getSelectedRow(); if ((selRow == -1) || !table.isRowSelected(table.rowAtPoint(e.getPoint()))) { table.setRowSelectionInterval(row, row); } RightClickMenu rightClickMenu = new RightClickMenu(JabRefGUI.getMainFrame(), panel); rightClickMenu.show(table, e.getX(), e.getY()); } /** * Process popup trigger events occurring on an icon cell in the table. Show a menu where the user can choose which * external resource to open for the entry. If no relevant external resources exist, let the normal popup trigger * handler do its thing instead. * * @param e The mouse event defining this popup trigger. * @param row The row where the event occurred. * @param column the MainTableColumn associated with this table cell. */ private void showIconRightClickMenu(MouseEvent e, int row, MainTableColumn column) { BibEntry entry = tableRows.get(row); JPopupMenu menu = new JPopupMenu(); boolean showDefaultPopup = true; // See if this is a simple file link field, or if it is a file-list // field that can specify a list of links: if (!column.getBibtexFields().isEmpty()) { for (String field : column.getBibtexFields()) { if (FieldName.FILE.equals(field)) { // We use a FileListTableModel to parse the field content: FileListTableModel fileList = new FileListTableModel(); entry.getField(field).ifPresent(fileList::setContent); for (int i = 0; i < fileList.getRowCount(); i++) { FileListEntry flEntry = fileList.getEntry(i); if (column.isFileFilter() && (!flEntry.getType().get().getName().equalsIgnoreCase(column.getColumnName()))) { continue; } String description = flEntry.getDescription(); if ((description == null) || (description.trim().isEmpty())) { description = flEntry.getLink(); } menu.add(new ExternalFileMenuItem(panel.frame(), entry, description, flEntry.getLink(), flEntry.getType().get().getIcon(), panel.getBibDatabaseContext(), flEntry.getType())); showDefaultPopup = false; } } else { if (SpecialField.isSpecialField(column.getColumnName())) { // full pop should be shown as left click already shows short popup showDefaultPopup = true; } else { Optional<String> content = entry.getField(field); if (content.isPresent()) { Icon icon; JLabel iconLabel = GUIGlobals.getTableIcon(field); if (iconLabel == null) { icon = IconTheme.JabRefIcon.FILE.getIcon(); } else { icon = iconLabel.getIcon(); } menu.add(new ExternalFileMenuItem(panel.frame(), entry, content.get(), content.get(), icon, panel.getBibDatabaseContext(), field)); if (field.equals(FieldName.DOI)) { menu.add(new CopyDoiUrlAction(content.get())); } showDefaultPopup = false; } } } } if (showDefaultPopup) { processPopupTrigger(e, row); } else { menu.show(table, e.getX(), e.getY()); } } } public void entryEditorClosing(EntryEditor editor) { preview.setEntry(editor.getEntry()); if (previewActive) { panel.showPreview(preview); } else { panel.hideBottomComponent(); } panel.adjustSplitter(); table.requestFocus(); } @Override public void mouseEntered(MouseEvent e) { // Do nothing } @Override public void mouseExited(MouseEvent e) { // Do nothing } public void setPreviewActive(boolean enabled) { previewActive = enabled; if (previewActive) { if (!table.getSelected().isEmpty()) { updatePreview(table.getSelected().get(0), false); } } else { panel.hideBottomComponent(); } } public void nextPreviewStyle() { cyclePreview(Globals.prefs.getPreviewPreferences().getPreviewCyclePosition() + 1); } public void previousPreviewStyle() { cyclePreview(Globals.prefs.getPreviewPreferences().getPreviewCyclePosition() - 1); } private void cyclePreview(int newPosition) { PreviewPreferences previewPreferences = Globals.prefs.getPreviewPreferences() .getBuilder() .withPreviewCyclePosition(newPosition) .build(); Globals.prefs.storePreviewPreferences(previewPreferences); preview.updateLayout(); preview.update(); panel.showPreview(preview); if (!table.getSelected().isEmpty()) { updatePreview(table.getSelected().get(0), true); } } /** * Receive key event on the main table. If the key is a letter or a digit, * we should select the first entry in the table which starts with the given * letter in the column by which the table is sorted. * @param e The KeyEvent */ @Override public void keyTyped(KeyEvent e) { if ((!e.isActionKey()) && Character.isLetterOrDigit(e.getKeyChar()) && (e.getModifiers() == 0)) { long time = System.currentTimeMillis(); final long QUICK_JUMP_TIMEOUT = 2000; if ((time - lastPressedTime) > QUICK_JUMP_TIMEOUT) { lastPressedCount = 0; // Reset last pressed character } // Update timestamp: lastPressedTime = time; // Add the new char to the search array: int c = e.getKeyChar(); if (lastPressedCount < lastPressed.length) { lastPressed[lastPressedCount] = c; lastPressedCount++; } int sortingColumn = table.getSortingColumn(0); if (sortingColumn == -1) { return; // No sorting? TODO: look up by author, etc.? } // TODO: the following lookup should be done by a faster algorithm, // such as binary search. But the table may not be sorted properly, // due to marked entries, search etc., which rules out the binary search. for (int i = 0; i < table.getRowCount(); i++) { Object o = table.getValueAt(i, sortingColumn); if (o == null) { continue; } String s = o.toString().toLowerCase(Locale.ROOT); if (s.length() >= lastPressedCount) { for (int j = 0; j < lastPressedCount; j++) { if (s.charAt(j) != lastPressed[j]) { break; // Escape the loop immediately when we find a mismatch } else if (j == (lastPressedCount - 1)) { // We found a match: table.setRowSelectionInterval(i, i); table.ensureVisible(i); return; } } } } } else if (e.getKeyChar() == KeyEvent.VK_ESCAPE) { lastPressedCount = 0; } panel.frame().updateEnabledState(); } @Override public void keyReleased(KeyEvent e) { // Do nothing } @Override public void keyPressed(KeyEvent e) { // Do nothing } @Override public void focusGained(FocusEvent e) { // Do nothing } @Override public void focusLost(FocusEvent e) { lastPressedCount = 0; // Reset quick jump when focus is lost. } public PreviewPanel getPreview() { return preview; } }