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