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