JAL-3048 fix NPE in file chooser callbacks map
[jalview.git] / src / jalview / io / JalviewFileChooser.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 //////////////////////////////////////////////////////////////////
22 package jalview.io;
23
24 import jalview.bin.Cache;
25 import jalview.gui.JvOptionPane;
26 import jalview.util.MessageManager;
27 import jalview.util.Platform;
28 import jalview.util.dialogrunner.DialogRunnerI;
29
30 import java.awt.Component;
31 import java.awt.Dimension;
32 import java.awt.EventQueue;
33 import java.awt.HeadlessException;
34 import java.awt.event.MouseAdapter;
35 import java.awt.event.MouseEvent;
36 import java.beans.PropertyChangeEvent;
37 import java.beans.PropertyChangeListener;
38 import java.io.File;
39 import java.util.ArrayList;
40 import java.util.HashMap;
41 import java.util.List;
42 import java.util.Map;
43 import java.util.StringTokenizer;
44 import java.util.Vector;
45
46 import javax.swing.DefaultListCellRenderer;
47 import javax.swing.JFileChooser;
48 import javax.swing.JList;
49 import javax.swing.JPanel;
50 import javax.swing.JScrollPane;
51 import javax.swing.SpringLayout;
52 import javax.swing.plaf.basic.BasicFileChooserUI;
53
54 /**
55  * Enhanced file chooser dialog box.
56  *
57  * NOTE: bug on Windows systems when filechooser opened on directory to view
58  * files with colons in title.
59  *
60  * @author AMW
61  *
62  */
63 public class JalviewFileChooser extends JFileChooser implements DialogRunnerI,
64     PropertyChangeListener
65 {
66   private static final long serialVersionUID = 1L;
67
68   private Map<Object, Runnable> callbacks = new HashMap<>();
69   
70   File selectedFile = null;
71
72   /**
73    * Factory method to return a file chooser that offers readable alignment file
74    * formats
75    * 
76    * @param directory
77    * @param selected
78    * @return
79    */
80   public static JalviewFileChooser forRead(String directory,
81           String selected)
82   {
83     List<String> extensions = new ArrayList<>();
84     List<String> descs = new ArrayList<>();
85     for (FileFormatI format : FileFormats.getInstance().getFormats())
86     {
87       if (format.isReadable())
88       {
89         extensions.add(format.getExtensions());
90         descs.add(format.getName());
91       }
92     }
93     return new JalviewFileChooser(directory,
94             extensions.toArray(new String[extensions.size()]),
95             descs.toArray(new String[descs.size()]), selected, true);
96   }
97
98   /**
99    * Factory method to return a file chooser that offers writable alignment file
100    * formats
101    * 
102    * @param directory
103    * @param selected
104    * @return
105    */
106   public static JalviewFileChooser forWrite(String directory,
107           String selected)
108   {
109     // TODO in Java 8, forRead and forWrite can be a single method
110     // with a lambda expression parameter for isReadable/isWritable
111     List<String> extensions = new ArrayList<>();
112     List<String> descs = new ArrayList<>();
113     for (FileFormatI format : FileFormats.getInstance().getFormats())
114     {
115       if (format.isWritable())
116       {
117         extensions.add(format.getExtensions());
118         descs.add(format.getName());
119       }
120     }
121     return new JalviewFileChooser(directory,
122             extensions.toArray(new String[extensions.size()]),
123             descs.toArray(new String[descs.size()]), selected, false);
124   }
125
126   public JalviewFileChooser(String dir)
127   {
128     super(safePath(dir));
129     setAccessory(new RecentlyOpened());
130   }
131
132   public JalviewFileChooser(String dir, String[] suffix, String[] desc,
133           String selected)
134   {
135     this(dir, suffix, desc, selected, true);
136   }
137
138   /**
139    * Constructor for a single choice of file extension and description
140    * 
141    * @param extension
142    * @param desc
143    */
144   public JalviewFileChooser(String extension, String desc)
145   {
146     this(Cache.getProperty("LAST_DIRECTORY"), new String[] { extension },
147             new String[]
148             { desc }, desc, true);
149   }
150
151   JalviewFileChooser(String dir, String[] extensions, String[] descs,
152           String selected, boolean acceptAny)
153   {
154     super(safePath(dir));
155     if (extensions.length == descs.length)
156     {
157       List<String[]> formats = new ArrayList<>();
158       for (int i = 0; i < extensions.length; i++)
159       {
160         formats.add(new String[] { extensions[i], descs[i] });
161       }
162       init(formats, selected, acceptAny);
163     }
164     else
165     {
166       System.err.println("JalviewFileChooser arguments mismatch: "
167               + extensions + ", " + descs);
168     }
169   }
170
171   private static File safePath(String dir)
172   {
173     if (dir == null)
174     {
175       return null;
176     }
177
178     File f = new File(dir);
179     if (f.getName().indexOf(':') > -1)
180     {
181       return null;
182     }
183     return f;
184   }
185
186   /**
187    * Overridden for JalviewJS compatibility: only one thread in Javascript, 
188    * so we can't wait for user choice in another thread and then perform the 
189    * desired action
190    */
191   @Override
192   public int showOpenDialog(Component parent)
193   {
194     int value = super.showOpenDialog(this);
195     
196     /*
197      * code below here is not reached in JalviewJS, instead
198      * propertyChange() is called for dialog action
199      */
200     handleResponse(value);
201     return value;
202   }
203
204   /**
205    * 
206    * @param formats
207    *          a list of {extensions, description} for each file format
208    * @param selected
209    * @param acceptAny
210    *          if true, 'any format' option is included
211    */
212   void init(List<String[]> formats, String selected, boolean acceptAny)
213   {
214
215     JalviewFileFilter chosen = null;
216
217     // SelectAllFilter needs to be set first before adding further
218     // file filters to fix bug on Mac OSX
219     setAcceptAllFileFilterUsed(acceptAny);
220
221     for (String[] format : formats)
222     {
223       JalviewFileFilter jvf = new JalviewFileFilter(format[0], format[1]);
224       addChoosableFileFilter(jvf);
225       if ((selected != null) && selected.equalsIgnoreCase(format[1]))
226       {
227         chosen = jvf;
228       }
229     }
230
231     if (chosen != null)
232     {
233       setFileFilter(chosen);
234     }
235
236     setAccessory(new RecentlyOpened());
237   }
238
239   @Override
240   public void setFileFilter(javax.swing.filechooser.FileFilter filter)
241   {
242     super.setFileFilter(filter);
243
244     try
245     {
246       if (getUI() instanceof BasicFileChooserUI)
247       {
248         final BasicFileChooserUI fcui = (BasicFileChooserUI) getUI();
249         final String name = fcui.getFileName().trim();
250
251         if ((name == null) || (name.length() == 0))
252         {
253           return;
254         }
255
256         EventQueue.invokeLater(new Thread()
257         {
258           @Override
259           public void run()
260           {
261             String currentName = fcui.getFileName();
262             if ((currentName == null) || (currentName.length() == 0))
263             {
264               fcui.setFileName(name);
265             }
266           }
267         });
268       }
269     } catch (Exception ex)
270     {
271       ex.printStackTrace();
272       // Some platforms do not have BasicFileChooserUI
273     }
274   }
275
276   /**
277    * Returns the selected file format, or null if none selected
278    * 
279    * @return
280    */
281   public FileFormatI getSelectedFormat()
282   {
283     if (getFileFilter() == null)
284     {
285       return null;
286     }
287
288     /*
289      * logic here depends on option description being formatted as 
290      * formatName (extension, extension...)
291      * or the 'no option selected' value
292      * All Files
293      * @see JalviewFileFilter.getDescription
294      */
295     String format = getFileFilter().getDescription();
296     int parenPos = format.indexOf("(");
297     if (parenPos > 0)
298     {
299       format = format.substring(0, parenPos).trim();
300       try
301       {
302         return FileFormats.getInstance().forName(format);
303       } catch (IllegalArgumentException e)
304       {
305         System.err.println("Unexpected format: " + format);
306       }
307     }
308     return null;
309   }
310
311   @Override
312   public File getSelectedFile()
313   {
314     File f = super.getSelectedFile();
315     return f == null ? selectedFile : f;
316   }
317
318   @Override
319   public int showSaveDialog(Component parent) throws HeadlessException
320   {
321     this.setAccessory(null);
322     // Java 9,10,11 on OSX - clear selected file so name isn't auto populated
323     this.setSelectedFile(null);
324
325     return super.showSaveDialog(parent);
326   }
327
328   /**
329    * If doing a Save, and an existing file is chosen or entered, prompt for
330    * confirmation of overwrite. Proceed if Yes, else leave the file chooser
331    * open.
332    * 
333    * @see https://stackoverflow.com/questions/8581215/jfilechooser-and-checking-for-overwrite
334    */
335   @Override
336   public void approveSelection()
337   {
338     if (getDialogType() != SAVE_DIALOG)
339     {
340       super.approveSelection();
341       return;
342     }
343
344     selectedFile = getSelectedFile();
345
346     if (selectedFile == null)
347     {
348       // Workaround for Java 9,10 on OSX - no selected file, but there is a
349       // filename typed in
350       try
351       {
352         String filename = ((BasicFileChooserUI) getUI()).getFileName();
353         if (filename != null && filename.length() > 0)
354         {
355           selectedFile = new File(getCurrentDirectory(), filename);
356         }
357       } catch (Throwable x)
358       {
359         System.err.println(
360                 "Unexpected exception when trying to get filename.");
361         x.printStackTrace();
362       }
363       // TODO: ENSURE THAT FILES SAVED WITH A ':' IN THE NAME ARE REFUSED AND
364       // THE
365       // USER PROMPTED FOR A NEW FILENAME
366     }
367
368     if (selectedFile == null)
369     {
370       return;
371     }
372
373     if (getFileFilter() instanceof JalviewFileFilter)
374     {
375       JalviewFileFilter jvf = (JalviewFileFilter) getFileFilter();
376
377       if (!jvf.accept(selectedFile))
378       {
379         String withExtension = getSelectedFile().getName() + "."
380                 + jvf.getAcceptableExtension();
381         selectedFile = (new File(getCurrentDirectory(), withExtension));
382         setSelectedFile(selectedFile);
383       }
384     }
385
386     if (selectedFile.exists())
387     {
388       int confirm = JvOptionPane.showConfirmDialog(this,
389               MessageManager.getString("label.overwrite_existing_file"),
390               MessageManager.getString("label.file_already_exists"),
391               JvOptionPane.YES_NO_OPTION);
392       if (confirm != JvOptionPane.YES_OPTION)
393       {
394         return;
395       }
396     }
397
398     super.approveSelection();
399   }
400
401   void recentListSelectionChanged(Object selection)
402   {
403     setSelectedFile(null);
404     if (selection != null)
405     {
406       File file = new File((String) selection);
407       if (getFileFilter() instanceof JalviewFileFilter)
408       {
409         JalviewFileFilter jvf = (JalviewFileFilter) this.getFileFilter();
410
411         if (!jvf.accept(file))
412         {
413           setFileFilter(getChoosableFileFilters()[0]);
414         }
415       }
416
417       setSelectedFile(file);
418     }
419   }
420
421   class RecentlyOpened extends JPanel
422   {
423     private static final long serialVersionUID = 1L;
424     JList<String> list;
425
426     RecentlyOpened()
427     {
428       setPreferredSize(new Dimension(300,100));
429       String historyItems = Cache.getProperty("RECENT_FILE");
430       StringTokenizer st;
431       Vector<String> recent = new Vector<>();
432
433       if (historyItems != null)
434       {
435         st = new StringTokenizer(historyItems, "\t");
436
437         while (st.hasMoreTokens())
438         {
439           recent.addElement(st.nextToken());
440         }
441       }
442
443       list = new JList<>(recent);
444   
445       DefaultListCellRenderer dlcr = new DefaultListCellRenderer();
446 //      dlcr.setHorizontalAlignment(DefaultListCellRenderer.RIGHT);
447       list.setCellRenderer(dlcr);
448
449       list.addMouseListener(new MouseAdapter()
450       {
451         @Override
452         public void mousePressed(MouseEvent evt)
453         {
454           recentListSelectionChanged(list.getSelectedValue());
455         }
456       });
457
458       this.setBorder(new javax.swing.border.TitledBorder(
459               MessageManager.getString("label.recently_opened")));
460
461       final JScrollPane scroller = new JScrollPane(list);
462
463       SpringLayout layout = new SpringLayout();
464       layout.putConstraint(SpringLayout.WEST, scroller, 5,
465               SpringLayout.WEST, this);
466       layout.putConstraint(SpringLayout.NORTH, scroller, 5,
467               SpringLayout.NORTH, this);
468
469       if (Platform.isAMac())
470       {
471         scroller.setPreferredSize(new Dimension(500, 100));
472       }
473       else
474       {
475         scroller.setPreferredSize(new Dimension(530, 200));
476       }
477
478       this.add(scroller);
479
480       javax.swing.SwingUtilities.invokeLater(new Runnable()
481       {
482         @Override
483         public void run()
484         {
485           scroller.getHorizontalScrollBar()
486                   .setValue(scroller.getHorizontalScrollBar().getMaximum());
487         }
488       });
489
490     }
491
492   }
493
494   @Override
495   public DialogRunnerI setResponseHandler(Object response, Runnable action)
496   {
497         callbacks.put(response,  action);
498         return this;
499   }
500
501   @Override
502   public void handleResponse(Object response)
503   {
504     /*
505          * this test is for NaN in Chrome
506          */
507     if (response != null && !response.equals(response))
508     {
509       return;
510     }
511     Runnable action = callbacks.get(response);
512     if (action != null)
513     {
514       action.run();
515     }
516   }
517
518   /**
519    * JalviewJS signals file selection by a property change event
520    * for property "SelectedFile".  This methods responds to that by
521    * running the response action for 'OK' in the dialog.
522    * 
523    * @param evt
524    */
525   @Override
526   public void propertyChange(PropertyChangeEvent evt)
527   {
528     // TODO other properties need runners...
529     switch (evt.getPropertyName())
530     {
531     /*
532      * property name here matches that used in JFileChooser.js
533      */
534     case "SelectedFile": 
535       handleResponse(APPROVE_OPTION);
536       break;
537     }
538   }
539 }