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