JAL-4061 use alignment panel’s feature renderer to limit search to just the visible...
[jalview.git] / src / jalview / gui / Finder.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.gui;
22
23 import java.awt.Dimension;
24 import java.awt.event.ActionEvent;
25 import java.awt.event.FocusAdapter;
26 import java.awt.event.FocusEvent;
27 import java.awt.event.KeyEvent;
28 import java.util.ArrayList;
29 import java.util.HashMap;
30 import java.util.List;
31 import java.util.Locale;
32 import java.util.Map;
33 import java.util.regex.Pattern;
34 import java.util.regex.PatternSyntaxException;
35
36 import javax.swing.AbstractAction;
37 import javax.swing.JComponent;
38 import javax.swing.JInternalFrame;
39 import javax.swing.JLayeredPane;
40 import javax.swing.KeyStroke;
41 import javax.swing.event.InternalFrameAdapter;
42 import javax.swing.event.InternalFrameEvent;
43
44 import jalview.api.AlignViewportI;
45 import jalview.api.FinderI;
46 import jalview.datamodel.SearchResultMatchI;
47 import jalview.datamodel.SearchResultsI;
48 import jalview.datamodel.SequenceFeature;
49 import jalview.datamodel.SequenceI;
50 import jalview.jbgui.GFinder;
51 import jalview.util.MessageManager;
52
53 /**
54  * Performs the menu option for searching the alignment, for the next or all
55  * matches. If matches are found, they are highlighted, and the user has the
56  * option to create a new feature on the alignment for the matched positions.
57  * 
58  * Searches can be for a simple base sequence, or may use a regular expression.
59  * Any gaps are ignored.
60  * 
61  * @author $author$
62  * @version $Revision$
63  */
64 public class Finder extends GFinder
65 {
66   private static final int MIN_WIDTH = 350;
67
68   private static final int MIN_HEIGHT = 120;
69
70   private static final int MY_HEIGHT = 150;
71
72   private static final int MY_WIDTH = 400;
73
74   private AlignViewportI av;
75
76   private AlignmentPanel ap;
77
78   private JInternalFrame frame;
79
80   /*
81    * Finder agent per viewport searched
82    */
83   private Map<AlignViewportI, FinderI> finders;
84
85   private SearchResultsI searchResults;
86
87   /*
88    * true if Finder always acts on the same alignment,
89    * false if it acts on the alignment with focus
90    */
91   private boolean focusFixed;
92
93   /**
94    * Constructor given an associated alignment panel. Constructs and displays an
95    * internal frame where the user can enter a search string. The Finder may
96    * have 'fixed focus' (always act the panel for which it is constructed), or
97    * not (acts on the alignment that has focus). An optional 'scope' may be
98    * added to be shown in the title of the Finder frame.
99    * 
100    * @param alignPanel
101    * @param fixedFocus
102    * @param scope
103    */
104   public Finder(AlignmentPanel alignPanel, boolean fixedFocus, String scope)
105   {
106     av = alignPanel.getAlignViewport();
107     ap = alignPanel;
108     focusFixed = fixedFocus;
109     finders = new HashMap<>();
110     frame = new JInternalFrame();
111     frame.setContentPane(this);
112     frame.setLayer(JLayeredPane.PALETTE_LAYER);
113     frame.addInternalFrameListener(new InternalFrameAdapter()
114     {
115       @Override
116       public void internalFrameClosing(InternalFrameEvent e)
117       {
118         closeAction();
119       }
120     });
121     frame.addFocusListener(new FocusAdapter()
122     {
123       @Override
124       public void focusGained(FocusEvent e)
125       {
126         /*
127          * ensure 'ignore hidden columns' is only enabled
128          * if the alignment with focus has hidden columns
129          */
130         getFocusedViewport();
131       }
132     });
133
134     addEscapeHandler();
135
136     String title = MessageManager.getString("label.find");
137     if (scope != null)
138     {
139       title += " " + scope;
140     }
141     Desktop.addInternalFrame(frame, title, MY_WIDTH, MY_HEIGHT);
142     frame.setMinimumSize(new Dimension(MIN_WIDTH, MIN_HEIGHT));
143     searchBox.getComponent().requestFocus();
144   }
145
146   /**
147    * Add a handler for the Escape key when the window has focus
148    */
149   private void addEscapeHandler()
150   {
151     getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
152             .put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "Cancel");
153     getRootPane().getActionMap().put("Cancel", new AbstractAction()
154     {
155       @Override
156       public void actionPerformed(ActionEvent e)
157       {
158         closeAction();
159       }
160     });
161   }
162
163   /**
164    * Performs the 'Find Next' action on the alignment panel with focus
165    */
166   @Override
167   public void findNext_actionPerformed()
168   {
169     if (getFocusedViewport())
170     {
171       doSearch(false);
172     }
173   }
174
175   /**
176    * Performs the 'Find All' action on the alignment panel with focus
177    */
178   @Override
179   public void findAll_actionPerformed()
180   {
181     if (getFocusedViewport())
182     {
183       doSearch(true);
184     }
185   }
186
187   /**
188    * if !focusfixed and not in a desktop environment, checks that av and ap are
189    * valid. Otherwise, gets the topmost alignment window and sets av and ap
190    * accordingly. Also sets the 'ignore hidden' checkbox disabled if the
191    * viewport has no hidden columns.
192    * 
193    * @return false if no alignment window was found
194    */
195   boolean getFocusedViewport()
196   {
197     if (focusFixed || Desktop.desktop == null)
198     {
199       if (ap != null && av != null)
200       {
201         ignoreHidden.setEnabled(av.hasHiddenColumns());
202         return true;
203       }
204       // we aren't in a desktop environment, so give up now.
205       return false;
206     }
207     // now checks further down the window stack to fix bug
208     // https://mantis.lifesci.dundee.ac.uk/view.php?id=36008
209     JInternalFrame[] frames = Desktop.desktop.getAllFrames();
210     for (int f = 0; f < frames.length; f++)
211     {
212       JInternalFrame alignFrame = frames[f];
213       if (alignFrame != null && alignFrame instanceof AlignFrame
214               && !alignFrame.isIcon())
215       {
216         av = ((AlignFrame) alignFrame).viewport;
217         ap = ((AlignFrame) alignFrame).alignPanel;
218         ignoreHidden.setEnabled(av.hasHiddenColumns());
219         return true;
220       }
221     }
222     return false;
223   }
224
225   /**
226    * Opens a dialog that allows the user to create sequence features for the
227    * find match results
228    */
229   @Override
230   public void createFeatures_actionPerformed()
231   {
232     if (searchResults.isEmpty())
233     {
234       return; // shouldn't happen
235     }
236     List<SequenceI> seqs = new ArrayList<>();
237     List<SequenceFeature> features = new ArrayList<>();
238
239     String searchString = searchBox.getUserInput();
240     String desc = "Search Results";
241
242     /*
243      * assemble dataset sequences, and template new sequence features,
244      * for the amend features dialog
245      */
246     for (SearchResultMatchI match : searchResults.getResults())
247     {
248       seqs.add(match.getSequence().getDatasetSequence());
249       features.add(new SequenceFeature(searchString, desc, match.getStart(),
250               match.getEnd(), desc));
251     }
252
253     new FeatureEditor(ap, seqs, features, true).showDialog();
254   }
255
256   @Override
257   protected void copyToClipboard_actionPerformed()
258   {
259     if (searchResults.isEmpty())
260     {
261       return; // shouldn't happen
262     }
263     // assume viewport controller has same searchResults as we do...
264     ap.alignFrame.avc.copyHighlightedRegionsToClipboard();
265   }
266
267   /**
268    * Search the alignment for the next or all matches. If 'all matches', a
269    * dialog is shown with the number of sequence ids and subsequences matched.
270    * 
271    * @param doFindAll
272    */
273   void doSearch(boolean doFindAll)
274   {
275     createFeatures.setEnabled(false);
276     copyToClipboard.setEnabled(false);
277
278     String searchString = searchBox.getUserInput();
279
280     if (isInvalidSearchString(searchString))
281     {
282       return;
283     }
284     // TODO: extend finder to match descriptions, features and annotation, and
285     // other stuff
286     // TODO: add switches to control what is searched - sequences, IDS,
287     // descriptions, features
288     FinderI finder = finders.get(av);
289     if (finder == null)
290     {
291       /*
292        * first time we've searched this viewport
293        */
294       finder = new jalview.analysis.Finder(av);
295       finders.put(av, finder);
296     }
297     finder.setFeatureRenderer(ap.getFeatureRenderer());
298
299     boolean isCaseSensitive = caseSensitive.isSelected();
300     boolean doSearchDescription = searchDescription.isSelected();
301     boolean doSearchfeatures = searchFeatures.isSelected();
302     boolean skipHidden = ignoreHidden.isSelected();
303     if (doFindAll)
304     {
305       finder.findAll(searchString, isCaseSensitive, doSearchDescription,
306               doSearchfeatures, skipHidden);
307     }
308     else
309     {
310       finder.findNext(searchString, isCaseSensitive, doSearchDescription,
311               doSearchfeatures, skipHidden);
312     }
313
314     searchResults = finder.getSearchResults();
315     List<SequenceI> idMatch = finder.getIdMatches();
316     ap.getIdPanel().highlightSearchResults(idMatch);
317
318     if (searchResults.isEmpty())
319     {
320       searchResults = null;
321     }
322     else
323     {
324       createFeatures.setEnabled(true);
325       copyToClipboard.setEnabled(true);
326     }
327
328     searchBox.updateCache();
329
330     ap.highlightSearchResults(searchResults);
331     // TODO: add enablers for 'SelectSequences' or 'SelectColumns' or
332     // 'SelectRegion' selection
333     if (idMatch.isEmpty() && searchResults == null)
334     {
335       JvOptionPane.showInternalMessageDialog(this,
336               MessageManager.getString("label.finished_searching"), null,
337               JvOptionPane.PLAIN_MESSAGE);
338     }
339     else
340     {
341       if (doFindAll)
342       {
343         // then we report the matches that were found
344         StringBuilder message = new StringBuilder();
345         if (idMatch.size() > 0)
346         {
347           message.append(idMatch.size()).append(" IDs");
348         }
349         if (searchResults != null)
350         {
351           if (idMatch.size() > 0 && searchResults.getCount() > 0)
352           {
353             message.append(" ").append(MessageManager.getString("label.and")
354                     .toLowerCase(Locale.ROOT)).append(" ");
355           }
356           message.append(MessageManager.formatMessage(
357                   "label.subsequence_matches_found",
358                   searchResults.getCount()));
359         }
360         JvOptionPane.showInternalMessageDialog(this, message.toString(),
361                 null, JvOptionPane.INFORMATION_MESSAGE);
362       }
363     }
364   }
365
366   /**
367    * Displays an error dialog, and answers false, if the search string is
368    * invalid, else answers true.
369    * 
370    * @param searchString
371    * @return
372    */
373   protected boolean isInvalidSearchString(String searchString)
374   {
375     String error = getSearchValidationError(searchString);
376     if (error == null)
377     {
378       return false;
379     }
380     JvOptionPane.showInternalMessageDialog(this, error,
381             MessageManager.getString("label.invalid_search"), // $NON-NLS-1$
382             JvOptionPane.ERROR_MESSAGE);
383     return true;
384   }
385
386   /**
387    * Returns an error message string if the search string is invalid, else
388    * returns null.
389    * 
390    * Currently validation is limited to checking the string is not empty, and is
391    * a valid regular expression (simple searches for base sub-sequences will
392    * pass this test). Additional validations may be added in future if the
393    * search syntax is expanded.
394    * 
395    * @param searchString
396    * @return
397    */
398   protected String getSearchValidationError(String searchString)
399   {
400     String error = null;
401     if (searchString == null || searchString.length() == 0)
402     {
403       error = MessageManager.getString("label.invalid_search");
404     }
405     try
406     {
407       Pattern.compile(searchString);
408     } catch (PatternSyntaxException e)
409     {
410       error = MessageManager.getString("error.invalid_regex") + ": "
411               + e.getDescription();
412     }
413     return error;
414   }
415
416   protected void closeAction()
417   {
418     frame.setVisible(false);
419     frame.dispose();
420     searchBox.persistCache();
421     if (getFocusedViewport())
422     {
423       ap.alignFrame.requestFocus();
424     }
425   }
426 }