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