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