package org.jabref.gui.groups; import java.awt.BorderLayout; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; import java.awt.event.ActionEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.Enumeration; import java.util.List; import java.util.Optional; import javax.swing.AbstractAction; import javax.swing.BorderFactory; import javax.swing.ButtonGroup; import javax.swing.JButton; import javax.swing.JCheckBoxMenuItem; import javax.swing.JMenu; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JRadioButtonMenuItem; import javax.swing.JScrollPane; import javax.swing.ScrollPaneConstants; import javax.swing.SwingUtilities; import javax.swing.event.PopupMenuEvent; import javax.swing.event.PopupMenuListener; import javax.swing.event.TreeSelectionEvent; import javax.swing.event.TreeSelectionListener; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreePath; import javax.swing.undo.AbstractUndoableEdit; import javax.swing.undo.CompoundEdit; import javafx.application.Platform; import javafx.embed.swing.JFXPanel; import javafx.scene.Scene; import javafx.scene.layout.StackPane; import org.jabref.Globals; import org.jabref.gui.BasePanel; import org.jabref.gui.IconTheme; import org.jabref.gui.JabRefFrame; import org.jabref.gui.SidePaneComponent; import org.jabref.gui.SidePaneManager; import org.jabref.gui.help.HelpAction; import org.jabref.gui.keyboard.KeyBinding; import org.jabref.gui.maintable.MainTableDataModel; import org.jabref.logic.groups.DefaultGroupsFactory; import org.jabref.logic.help.HelpFile; import org.jabref.logic.l10n.Localization; import org.jabref.model.entry.BibEntry; import org.jabref.model.groups.AllEntriesGroup; import org.jabref.model.groups.GroupTreeNode; import org.jabref.model.groups.event.GroupUpdatedEvent; import org.jabref.model.metadata.MetaData; import org.jabref.model.search.SearchMatcher; import org.jabref.preferences.JabRefPreferences; import com.google.common.eventbus.Subscribe; /** * The whole UI component holding the groups tree and the buttons */ public class GroupSelector extends SidePaneComponent implements TreeSelectionListener { protected final JabRefFrame frame; private final GroupsTree groupsTree; private final JPopupMenu groupsContextMenu = new JPopupMenu(); private final JPopupMenu settings = new JPopupMenu(); private final JRadioButtonMenuItem andCb = new JRadioButtonMenuItem(Localization.lang("Intersection"), true); private final JRadioButtonMenuItem floatCb = new JRadioButtonMenuItem(Localization.lang("Float"), true); private final JCheckBoxMenuItem invCb = new JCheckBoxMenuItem(Localization.lang("Inverted"), false); private final JCheckBoxMenuItem autoAssignGroup = new JCheckBoxMenuItem( Localization.lang("Automatically assign new entry to selected groups")); private final JMenu sortSubmenu = new JMenu(Localization.lang("Sort alphabetically")); private final NodeAction sortDirectSubgroupsPopupAction = new SortDirectSubgroupsAction(); private final NodeAction sortAllSubgroupsPopupAction = new SortAllSubgroupsAction(); private final ToggleAction toggleAction; private DefaultTreeModel groupsTreeModel; private GroupTreeNodeViewModel groupsRoot; /** * The first element for each group defines which field to use for the quicksearch. The next two define the name and * regexp for the group. */ public GroupSelector(JabRefFrame frame, SidePaneManager manager) { super(manager, IconTheme.JabRefIcon.TOGGLE_GROUPS.getIcon(), Localization.lang("Groups")); Globals.stateManager.activeGroupProperty() .addListener((observable, oldValue, newValue) -> updateShownEntriesAccordingToSelectedGroups(newValue)); toggleAction = new ToggleAction(Localization.menuTitle("Toggle groups interface"), Localization.lang("Toggle groups interface"), Globals.getKeyPrefs().getKey(KeyBinding.TOGGLE_GROUPS_INTERFACE), IconTheme.JabRefIcon.TOGGLE_GROUPS); this.frame = frame; floatCb.addChangeListener( event -> Globals.prefs.putBoolean(JabRefPreferences.GROUP_FLOAT_SELECTIONS, floatCb.isSelected())); andCb.addChangeListener( event -> Globals.prefs.putBoolean(JabRefPreferences.GROUP_INTERSECT_SELECTIONS, andCb.isSelected())); invCb.addChangeListener( event -> Globals.prefs.putBoolean(JabRefPreferences.GROUP_INVERT_SELECTIONS, invCb.isSelected())); JRadioButtonMenuItem highlCb = new JRadioButtonMenuItem(Localization.lang("Highlight"), false); if (Globals.prefs.getBoolean(JabRefPreferences.GROUP_FLOAT_SELECTIONS)) { floatCb.setSelected(true); highlCb.setSelected(false); } else { highlCb.setSelected(true); floatCb.setSelected(false); } JRadioButtonMenuItem orCb = new JRadioButtonMenuItem(Localization.lang("Union"), false); if (Globals.prefs.getBoolean(JabRefPreferences.GROUP_INTERSECT_SELECTIONS)) { andCb.setSelected(true); orCb.setSelected(false); } else { orCb.setSelected(true); andCb.setSelected(false); } autoAssignGroup.addChangeListener( event -> Globals.prefs.putBoolean(JabRefPreferences.AUTO_ASSIGN_GROUP, autoAssignGroup.isSelected())); invCb.setSelected(Globals.prefs.getBoolean(JabRefPreferences.GROUP_INVERT_SELECTIONS)); autoAssignGroup.setSelected(Globals.prefs.getBoolean(JabRefPreferences.AUTO_ASSIGN_GROUP)); JButton openSettings = new JButton(IconTheme.JabRefIcon.PREFERENCES.getSmallIcon()); settings.add(andCb); settings.add(orCb); settings.addSeparator(); settings.add(invCb); settings.addSeparator(); settings.add(autoAssignGroup); openSettings.addActionListener(e -> { if (!settings.isVisible()) { JButton src = (JButton) e.getSource(); autoAssignGroup.setSelected(Globals.prefs.getBoolean(JabRefPreferences.AUTO_ASSIGN_GROUP)); settings.show(src, 0, openSettings.getHeight()); } }); JButton helpButton = new HelpAction(Localization.lang("Help on groups"), HelpFile.GROUP) .getHelpButton(); Insets butIns = new Insets(0, 0, 0, 0); helpButton.setMargin(butIns); openSettings.setMargin(butIns); andCb.addActionListener(e -> valueChanged(null)); orCb.addActionListener(e -> valueChanged(null)); invCb.addActionListener(e -> valueChanged(null)); floatCb.addActionListener(e -> valueChanged(null)); highlCb.addActionListener(e -> valueChanged(null)); andCb.setToolTipText(Localization.lang("Display only entries belonging to all selected groups.")); orCb.setToolTipText(Localization.lang("Display all entries belonging to one or more of the selected groups.")); openSettings.setToolTipText(Localization.lang("Settings")); invCb.setToolTipText("<html>" + Localization.lang("Show entries <b>not</b> in group selection") + "</html>"); floatCb.setToolTipText(Localization.lang("Move entries in group selection to the top")); highlCb.setToolTipText(Localization.lang("Gray out entries not in group selection")); ButtonGroup bgr = new ButtonGroup(); bgr.add(andCb); bgr.add(orCb); ButtonGroup visMode = new ButtonGroup(); visMode.add(floatCb); visMode.add(highlCb); JPanel rootPanel = new JPanel(); GridBagLayout gbl = new GridBagLayout(); rootPanel.setLayout(gbl); GridBagConstraints con = new GridBagConstraints(); con.fill = GridBagConstraints.BOTH; con.weightx = 1; con.gridwidth = 1; con.gridy = 0; con.gridx = 0; con.gridx = 1; con.gridx = 2; gbl.setConstraints(openSettings, con); rootPanel.add(openSettings); con.gridx = 3; con.gridwidth = GridBagConstraints.REMAINDER; gbl.setConstraints(helpButton, con); rootPanel.add(helpButton); groupsTree = new GroupsTree(this); groupsTree.addTreeSelectionListener(this); JScrollPane groupsTreePane = new JScrollPane(groupsTree, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); groupsTreePane.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 0)); groupsTreePane.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 0)); con.gridwidth = GridBagConstraints.REMAINDER; con.weighty = 1; con.gridx = 0; con.gridwidth = 4; con.gridy = 1; gbl.setConstraints(groupsTreePane, con); rootPanel.add(groupsTreePane); add(rootPanel, BorderLayout.CENTER); groupsTree.setBorder(BorderFactory.createEmptyBorder(5, 10, 0, 0)); this.setTitle(Localization.lang("Groups")); definePopup(); setGroups(GroupTreeNode.fromGroup(DefaultGroupsFactory.getAllEntriesGroup())); JFXPanel groupsPane = new JFXPanel(); add(groupsPane); // Execute on JavaFX Application Thread Platform.runLater(() -> { StackPane root = new StackPane(); root.getChildren().addAll(new GroupTreeView().getView()); Scene scene = new Scene(root); groupsPane.setScene(scene); }); } private void definePopup() { // These key bindings are just to have the shortcuts displayed // in the popup menu. The actual keystroke processing is in // BasePanel (entryTable.addKeyListener(...)). groupsContextMenu.addSeparator(); sortSubmenu.add(sortDirectSubgroupsPopupAction); sortSubmenu.add(sortAllSubgroupsPopupAction); groupsContextMenu.add(sortSubmenu); groupsContextMenu.addSeparator(); groupsTree.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { if (e.isPopupTrigger()) { showPopup(e); } } @Override public void mouseReleased(MouseEvent e) { if (e.isPopupTrigger()) { showPopup(e); } } @Override public void mouseClicked(MouseEvent e) { TreePath path = groupsTree.getPathForLocation(e.getPoint().x, e.getPoint().y); if (path == null) { return; } GroupTreeNodeViewModel node = (GroupTreeNodeViewModel) path.getLastPathComponent(); // the root node is "AllEntries" and cannot be edited if (node.getNode().isRoot()) { return; } if ((e.getClickCount() == 2) && (e.getButton() == MouseEvent.BUTTON1)) { // edit //editGroupAction.actionPerformed(null); // dummy event } } }); // be sure to remove a possible border highlight when the popup menu // disappears groupsContextMenu.addPopupMenuListener(new PopupMenuListener() { @Override public void popupMenuWillBecomeVisible(PopupMenuEvent e) { // nothing to do } @Override public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { groupsTree.setHighlightBorderCell(null); } @Override public void popupMenuCanceled(PopupMenuEvent e) { groupsTree.setHighlightBorderCell(null); } }); } private void showPopup(MouseEvent e) { final TreePath path = groupsTree.getPathForLocation(e.getPoint().x, e.getPoint().y); sortSubmenu.setEnabled(path != null); if (path != null) { // some path dependent enabling/disabling GroupTreeNodeViewModel node = (GroupTreeNodeViewModel) path.getLastPathComponent(); sortDirectSubgroupsPopupAction.setNode(node); sortAllSubgroupsPopupAction.setNode(node); groupsTree.setHighlightBorderCell(node); if (node.canBeEdited()) { //editGroupPopupAction.setEnabled(false); //addGroupPopupAction.setEnabled(false); //removeGroupAndSubgroupsPopupAction.setEnabled(false); //removeGroupKeepSubgroupsPopupAction.setEnabled(false); } else { //editGroupPopupAction.setEnabled(true); //addGroupPopupAction.setEnabled(true); //addGroupPopupAction.setNode(node); //removeGroupAndSubgroupsPopupAction.setEnabled(true); //removeGroupKeepSubgroupsPopupAction.setEnabled(true); } sortSubmenu.setEnabled(!node.isLeaf()); //removeSubgroupsPopupAction.setEnabled(!node.isLeaf()); // add/remove entries to/from group List<BibEntry> selection = frame.getCurrentBasePanel().getSelectedEntries(); if (!selection.isEmpty()) { if (node.canAddEntries(selection)) { //addToGroup.setEnabled(true); } if (node.canRemoveEntries(selection)) { //removeFromGroup.setEnabled(true); } } } else { sortDirectSubgroupsPopupAction.setNode(null); sortAllSubgroupsPopupAction.setNode(null); } groupsContextMenu.show(groupsTree, e.getPoint().x, e.getPoint().y); } @Override public void valueChanged(TreeSelectionEvent e) { if (panel == null) { return; // ignore this event (happens for example if the file was closed) } /* if (getLeafsOfSelection().stream().allMatch(GroupTreeNodeViewModel::isAllEntriesGroup)) { panel.getMainTable().getTableModel().updateGroupingState(MainTableDataModel.DisplayOption.DISABLED); if (showOverlappingGroups.isSelected()) { groupsTree.setOverlappingGroups(Collections.emptyList()); } frame.output(Localization.lang("Displaying no groups") + "."); return; } */ updateShownEntriesAccordingToSelectedGroups(); } private void updateShownEntriesAccordingToSelectedGroups() { updateShownEntriesAccordingToSelectedGroups(Globals.stateManager.activeGroupProperty().get()); /*final MatcherSet searchRules = MatcherSets .build(andCb.isSelected() ? MatcherSets.MatcherType.AND : MatcherSets.MatcherType.OR); for (GroupTreeNodeViewModel node : getLeafsOfSelection()) { SearchMatcher searchRule = node.getNode().getSearchMatcher(); searchRules.addRule(searchRule); } SearchMatcher searchRule = invCb.isSelected() ? new NotMatcher(searchRules) : searchRules; GroupingWorker worker = new GroupingWorker(searchRule); worker.getWorker().run(); worker.getCallBack().update(); */ } private void updateShownEntriesAccordingToSelectedGroups(Optional<GroupTreeNode> selectedGroup) { if (!selectedGroup.isPresent()) { // No selected group, nothing to do return; } SearchMatcher searchRule = selectedGroup.get().getSearchMatcher(); GroupingWorker worker = new GroupingWorker(searchRule); worker.run(); worker.update(); } private GroupTreeNodeViewModel getFirstSelectedNode() { TreePath path = groupsTree.getSelectionPath(); if (path != null) { return (GroupTreeNodeViewModel) path.getLastPathComponent(); } return null; } /** * Revalidate the groups tree (e.g. after the data stored in the model has been changed) and maintain the current * selection and expansion state. */ public void revalidateGroups() { if (SwingUtilities.isEventDispatchThread()) { revalidateGroups(null); } else { SwingUtilities.invokeLater(() -> revalidateGroups(null)); } } /** * Revalidate the groups tree (e.g. after the data stored in the model has been changed) and maintain the current * selection and expansion state. * * @param node If this is non-null, the view is scrolled to make it visible. */ private void revalidateGroups(GroupTreeNodeViewModel node) { revalidateGroups(groupsTree.getSelectionPaths(), getExpandedPaths(), node); } /** * Revalidate the groups tree (e.g. after the data stored in the model has been changed) and set the specified * selection and expansion state. * * @param node If this is non-null, the view is scrolled to make it visible. */ private void revalidateGroups(TreePath[] selectionPaths, Enumeration<TreePath> expandedNodes, GroupTreeNodeViewModel node) { groupsTree.clearSelection(); if (selectionPaths != null) { groupsTree.setSelectionPaths(selectionPaths); } // tree is completely collapsed here if (expandedNodes != null) { while (expandedNodes.hasMoreElements()) { groupsTree.expandPath(expandedNodes.nextElement()); } } groupsTree.revalidate(); if (node != null) { groupsTree.scrollPathToVisible(node.getTreePath()); } } @Override public void componentOpening() { valueChanged(null); Globals.prefs.putBoolean(JabRefPreferences.GROUP_SIDEPANE_VISIBLE, Boolean.TRUE); } @Override public int getRescalingWeight() { return 1; } @Override public void componentClosing() { if (panel != null) { // panel may be null if no file is open any more panel.getMainTable().getTableModel().updateGroupingState(MainTableDataModel.DisplayOption.DISABLED); } getToggleAction().setSelected(false); Globals.prefs.putBoolean(JabRefPreferences.GROUP_SIDEPANE_VISIBLE, Boolean.FALSE); } private void setGroups(GroupTreeNode groupsRoot) { // We ignore the set group since this is handled via JavaFX this.groupsRoot = new GroupTreeNodeViewModel(new GroupTreeNode(DefaultGroupsFactory.getAllEntriesGroup())); //this.groupsRoot = new GroupTreeNodeViewModel(groupsRoot); groupsTreeModel = new DefaultTreeModel(this.groupsRoot); this.groupsRoot.subscribeToDescendantChanged(groupsTreeModel::nodeStructureChanged); groupsTree.setModel(groupsTreeModel); } /** * Adds the specified node as a child of the current root. The group contained in <b>newGroups </b> must not be of * type AllEntriesGroup, since every tree has exactly one AllEntriesGroup (its root). The <b>newGroups </b> are * inserted directly, i.e. they are not deepCopy()'d. */ public void addGroups(GroupTreeNode newGroups, CompoundEdit ce) { // TODO: This shouldn't be a method of GroupSelector // paranoia: ensure that there are never two instances of AllEntriesGroup if (newGroups.getGroup() instanceof AllEntriesGroup) { return; // this should be impossible anyway } newGroups.moveTo(groupsRoot.getNode()); UndoableAddOrRemoveGroup undo = new UndoableAddOrRemoveGroup(groupsRoot, new GroupTreeNodeViewModel(newGroups), UndoableAddOrRemoveGroup.ADD_NODE); ce.addEdit(undo); } public TreePath getSelectionPath() { return groupsTree.getSelectionPath(); } public void concludeAssignment(AbstractUndoableEdit undo, GroupTreeNode node, int assignedEntries) { if (undo == null) { frame.output(Localization.lang("The group \"%0\" already contains the selection.", node.getGroup().getName())); return; } panel.getUndoManager().addEdit(undo); panel.markBaseChanged(); panel.updateEntryEditorIfShowing(); final String groupName = node.getGroup().getName(); if (assignedEntries == 1) { frame.output(Localization.lang("Assigned 1 entry to group \"%0\".", groupName)); } else { frame.output(Localization.lang("Assigned %0 entries to group \"%1\".", String.valueOf(assignedEntries), groupName)); } } private GroupTreeNodeViewModel getGroupTreeRoot() { return groupsRoot; } private Enumeration<TreePath> getExpandedPaths() { return groupsTree.getExpandedDescendants(groupsRoot.getTreePath()); } /** * panel may be null to indicate that no file is currently open. */ @Override public void setActiveBasePanel(BasePanel panel) { super.setActiveBasePanel(panel); if (panel == null) { // hide groups frame.getSidePaneManager().hide(GroupSelector.class); return; } MetaData metaData = panel.getBibDatabaseContext().getMetaData(); if (metaData.getGroups().isPresent()) { setGroups(metaData.getGroups().get()); } else { GroupTreeNode newGroupsRoot = GroupTreeNode .fromGroup(DefaultGroupsFactory.getAllEntriesGroup()); metaData.setGroups(newGroupsRoot); setGroups(newGroupsRoot); } metaData.registerListener(this); synchronized (getTreeLock()) { validateTree(); } } public GroupsTree getGroupsTree() { return this.groupsTree; } @Subscribe public void listen(GroupUpdatedEvent updateEvent) { setGroups(updateEvent.getMetaData().getGroups().orElse(null)); } @Override public void grabFocus() { groupsTree.grabFocus(); } @Override public ToggleAction getToggleAction() { return toggleAction; } class GroupingWorker { private final SearchMatcher matcher; public GroupingWorker(SearchMatcher matcher) { this.matcher = matcher; } public void run() { for (BibEntry entry : panel.getDatabase().getEntries()) { boolean hit = matcher.isMatch(entry); entry.setGroupHit(hit); } } public void update() { // Show the result in the chosen way: if (Globals.prefs.getBoolean(JabRefPreferences.GRAY_OUT_NON_HITS)) { panel.getMainTable().getTableModel().updateGroupingState(MainTableDataModel.DisplayOption.FLOAT); } else { panel.getMainTable().getTableModel().updateGroupingState(MainTableDataModel.DisplayOption.FILTER); } panel.getMainTable().getTableModel().updateSortOrder(); panel.getMainTable().getTableModel().updateGroupFilter(); panel.getMainTable().scrollTo(0); frame.output(Localization.lang("Updated group selection") + "."); } } private abstract class NodeAction extends AbstractAction { private GroupTreeNodeViewModel node; public NodeAction(String s) { super(s); } public void setNode(GroupTreeNodeViewModel node) { this.node = node; } /** * Returns the node to use in this action. If a node has been set explicitly (via setNode), it is returned. * Otherwise, the first node in the current selection is returned. If all this fails, null is returned. */ public GroupTreeNodeViewModel getNodeToUse() { if (node != null) { return node; } return getFirstSelectedNode(); } } private class SortDirectSubgroupsAction extends NodeAction { public SortDirectSubgroupsAction() { super(Localization.lang("Immediate subgroups")); } @Override public void actionPerformed(ActionEvent ae) { final GroupTreeNodeViewModel node = getNodeToUse(); final UndoableModifySubtree undo = new UndoableModifySubtree(getGroupTreeRoot(), node, Localization.lang("sort subgroups")); groupsTree.sort(node, false); panel.getUndoManager().addEdit(undo); panel.markBaseChanged(); frame.output(Localization.lang("Sorted immediate subgroups.")); } } private class SortAllSubgroupsAction extends NodeAction { public SortAllSubgroupsAction() { super(Localization.lang("All subgroups (recursively)")); } @Override public void actionPerformed(ActionEvent ae) { final GroupTreeNodeViewModel node = getNodeToUse(); final UndoableModifySubtree undo = new UndoableModifySubtree(getGroupTreeRoot(), node, Localization.lang("sort subgroups")); groupsTree.sort(node, true); panel.getUndoManager().addEdit(undo); panel.markBaseChanged(); frame.output(Localization.lang("Sorted all subgroups recursively.")); } } }