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