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