f6455bfb4606d4038b5503e5ad94a4585b31e30b
[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.bin.Jalview;
26 import jalview.gui.JvOptionPane;
27 import jalview.util.MessageManager;
28 import jalview.util.Platform;
29 import jalview.util.dialogrunner.DialogRunnerI;
30
31 import java.awt.Component;
32 import java.awt.Dimension;
33 import java.awt.EventQueue;
34 import java.awt.HeadlessException;
35 import java.awt.event.MouseAdapter;
36 import java.awt.event.MouseEvent;
37 import java.beans.PropertyChangeEvent;
38 import java.beans.PropertyChangeListener;
39 import java.io.File;
40 import java.util.ArrayList;
41 import java.util.HashMap;
42 import java.util.List;
43 import java.util.Map;
44 import java.util.StringTokenizer;
45 import java.util.Vector;
46
47 import javax.swing.DefaultListCellRenderer;
48 import javax.swing.JFileChooser;
49 import javax.swing.JList;
50 import javax.swing.JPanel;
51 import javax.swing.JScrollPane;
52 import javax.swing.SpringLayout;
53 import javax.swing.plaf.basic.BasicFileChooserUI;
54
55 /**
56  * Enhanced file chooser dialog box.
57  *
58  * NOTE: bug on Windows systems when filechooser opened on directory to view
59  * files with colons in title.
60  *
61  * @author AMW
62  *
63  */
64 public class JalviewFileChooser extends JFileChooser implements DialogRunnerI,
65     PropertyChangeListener
66 {
67   private static final long serialVersionUID = 1L;
68
69   private Map<Object, Runnable> callbacks = new HashMap<>();
70   
71   File selectedFile = null;
72
73   /**
74    * Factory method to return a file chooser that offers readable alignment file
75    * formats
76    * 
77    * @param directory
78    * @param selected
79    * @return
80    */
81   public static JalviewFileChooser forRead(String directory,
82           String selected)
83   {
84     List<String> extensions = new ArrayList<>();
85     List<String> descs = new ArrayList<>();
86     for (FileFormatI format : FileFormats.getInstance().getFormats())
87     {
88       if (format.isReadable())
89       {
90         extensions.add(format.getExtensions());
91         descs.add(format.getName());
92       }
93     }
94     return new JalviewFileChooser(directory,
95             extensions.toArray(new String[extensions.size()]),
96             descs.toArray(new String[descs.size()]), selected, true);
97   }
98
99   /**
100    * Factory method to return a file chooser that offers writable alignment file
101    * formats
102    * 
103    * @param directory
104    * @param selected
105    * @return
106    */
107   public static JalviewFileChooser forWrite(String directory,
108           String selected)
109   {
110     // TODO in Java 8, forRead and forWrite can be a single method
111     // with a lambda expression parameter for isReadable/isWritable
112     List<String> extensions = new ArrayList<>();
113     List<String> descs = new ArrayList<>();
114     for (FileFormatI format : FileFormats.getInstance().getFormats())
115     {
116       if (format.isWritable())
117       {
118         extensions.add(format.getExtensions());
119         descs.add(format.getName());
120       }
121     }
122     return new JalviewFileChooser(directory,
123             extensions.toArray(new String[extensions.size()]),
124             descs.toArray(new String[descs.size()]), selected, false);
125   }
126
127   public JalviewFileChooser(String dir)
128   {
129     super(safePath(dir));
130     setAccessory(new RecentlyOpened());
131   }
132
133   public JalviewFileChooser(String dir, String[] suffix, String[] desc,
134           String selected)
135   {
136     this(dir, suffix, desc, selected, true);
137   }
138
139   /**
140    * Constructor for a single choice of file extension and description
141    * 
142    * @param extension
143    * @param desc
144    */
145   public JalviewFileChooser(String extension, String desc)
146   {
147     this(Cache.getProperty("LAST_DIRECTORY"), new String[] { extension },
148             new String[]
149             { desc }, desc, true);
150   }
151
152   JalviewFileChooser(String dir, String[] extensions, String[] descs,
153           String selected, boolean acceptAny)
154   {
155     super(safePath(dir));
156     if (extensions.length == descs.length)
157     {
158       List<String[]> formats = new ArrayList<>();
159       for (int i = 0; i < extensions.length; i++)
160       {
161         formats.add(new String[] { extensions[i], descs[i] });
162       }
163       init(formats, selected, acceptAny);
164     }
165     else
166     {
167       System.err.println("JalviewFileChooser arguments mismatch: "
168               + extensions + ", " + descs);
169     }
170   }
171
172   private static File safePath(String dir)
173   {
174     if (dir == null)
175     {
176       return null;
177     }
178
179     File f = new File(dir);
180     if (f.getName().indexOf(':') > -1)
181     {
182       return null;
183     }
184     return f;
185   }
186
187   /**
188    * Overridden for JalviewJS compatibility: only one thread in Javascript, 
189    * so we can't wait for user choice in another thread and then perform the 
190    * desired action
191    */
192   @Override
193   public int showOpenDialog(Component parent)
194   {
195     int value = super.showOpenDialog(this);
196     
197     if (!Jalview.isJS())
198     {
199       /*
200        * code here is not run in JalviewJS, instead
201        * propertyChange() is called for dialog action
202        */
203       handleResponse(value);
204     }
205     return value;
206   }
207
208   /**
209    * 
210    * @param formats
211    *          a list of {extensions, description} for each file format
212    * @param selected
213    * @param acceptAny
214    *          if true, 'any format' option is included
215    */
216   void init(List<String[]> formats, String selected, boolean acceptAny)
217   {
218
219     JalviewFileFilter chosen = null;
220
221     // SelectAllFilter needs to be set first before adding further
222     // file filters to fix bug on Mac OSX
223     setAcceptAllFileFilterUsed(acceptAny);
224
225     for (String[] format : formats)
226     {
227       JalviewFileFilter jvf = new JalviewFileFilter(format[0], format[1]);
228       addChoosableFileFilter(jvf);
229       if ((selected != null) && selected.equalsIgnoreCase(format[1]))
230       {
231         chosen = jvf;
232       }
233     }
234
235     if (chosen != null)
236     {
237       setFileFilter(chosen);
238     }
239
240     setAccessory(new RecentlyOpened());
241   }
242
243   @Override
244   public void setFileFilter(javax.swing.filechooser.FileFilter filter)
245   {
246     super.setFileFilter(filter);
247
248     try
249     {
250       if (getUI() instanceof BasicFileChooserUI)
251       {
252         final BasicFileChooserUI fcui = (BasicFileChooserUI) getUI();
253         final String name = fcui.getFileName().trim();
254
255         if ((name == null) || (name.length() == 0))
256         {
257           return;
258         }
259
260         EventQueue.invokeLater(new Thread()
261         {
262           @Override
263           public void run()
264           {
265             String currentName = fcui.getFileName();
266             if ((currentName == null) || (currentName.length() == 0))
267             {
268               fcui.setFileName(name);
269             }
270           }
271         });
272       }
273     } catch (Exception ex)
274     {
275       ex.printStackTrace();
276       // Some platforms do not have BasicFileChooserUI
277     }
278   }
279
280   /**
281    * Returns the selected file format, or null if none selected
282    * 
283    * @return
284    */
285   public FileFormatI getSelectedFormat()
286   {
287     if (getFileFilter() == null)
288     {
289       return null;
290     }
291
292     /*
293      * logic here depends on option description being formatted as 
294      * formatName (extension, extension...)
295      * or the 'no option selected' value
296      * All Files
297      * @see JalviewFileFilter.getDescription
298      */
299     String format = getFileFilter().getDescription();
300     int parenPos = format.indexOf("(");
301     if (parenPos > 0)
302     {
303       format = format.substring(0, parenPos).trim();
304       try
305       {
306         return FileFormats.getInstance().forName(format);
307       } catch (IllegalArgumentException e)
308       {
309         System.err.println("Unexpected format: " + format);
310       }
311     }
312     return null;
313   }
314
315   @Override
316   public File getSelectedFile()
317   {
318     File f = super.getSelectedFile();
319     return f == null ? selectedFile : f;
320   }
321
322   @Override
323   public int showSaveDialog(Component parent) throws HeadlessException
324   {
325     this.setAccessory(null);
326     // Java 9,10,11 on OSX - clear selected file so name isn't auto populated
327     this.setSelectedFile(null);
328
329     return super.showSaveDialog(parent);
330   }
331
332   /**
333    * If doing a Save, and an existing file is chosen or entered, prompt for
334    * confirmation of overwrite. Proceed if Yes, else leave the file chooser
335    * open.
336    * 
337    * @see https://stackoverflow.com/questions/8581215/jfilechooser-and-checking-for-overwrite
338    */
339   @Override
340   public void approveSelection()
341   {
342     if (getDialogType() != SAVE_DIALOG)
343     {
344       super.approveSelection();
345       return;
346     }
347
348     selectedFile = getSelectedFile();
349
350     if (selectedFile == null)
351     {
352       // Workaround for Java 9,10 on OSX - no selected file, but there is a
353       // filename typed in
354       try
355       {
356         String filename = ((BasicFileChooserUI) getUI()).getFileName();
357         if (filename != null && filename.length() > 0)
358         {
359           selectedFile = new File(getCurrentDirectory(), filename);
360         }
361       } catch (Throwable x)
362       {
363         System.err.println(
364                 "Unexpected exception when trying to get filename.");
365         x.printStackTrace();
366       }
367       // TODO: ENSURE THAT FILES SAVED WITH A ':' IN THE NAME ARE REFUSED AND
368       // THE
369       // USER PROMPTED FOR A NEW FILENAME
370     }
371
372     if (selectedFile == null)
373     {
374       return;
375     }
376
377     if (getFileFilter() instanceof JalviewFileFilter)
378     {
379       JalviewFileFilter jvf = (JalviewFileFilter) getFileFilter();
380
381       if (!jvf.accept(selectedFile))
382       {
383         String withExtension = getSelectedFile().getName() + "."
384                 + jvf.getAcceptableExtension();
385         selectedFile = (new File(getCurrentDirectory(), withExtension));
386         setSelectedFile(selectedFile);
387       }
388     }
389
390     if (selectedFile.exists())
391     {
392       int confirm = JvOptionPane.showConfirmDialog(this,
393               MessageManager.getString("label.overwrite_existing_file"),
394               MessageManager.getString("label.file_already_exists"),
395               JvOptionPane.YES_NO_OPTION);
396       if (confirm != JvOptionPane.YES_OPTION)
397       {
398         return;
399       }
400     }
401
402     super.approveSelection();
403   }
404
405   void recentListSelectionChanged(Object selection)
406   {
407     setSelectedFile(null);
408     if (selection != null)
409     {
410       File file = new File((String) selection);
411       if (getFileFilter() instanceof JalviewFileFilter)
412       {
413         JalviewFileFilter jvf = (JalviewFileFilter) this.getFileFilter();
414
415         if (!jvf.accept(file))
416         {
417           setFileFilter(getChoosableFileFilters()[0]);
418         }
419       }
420
421       setSelectedFile(file);
422     }
423   }
424
425   class RecentlyOpened extends JPanel
426   {
427     private static final long serialVersionUID = 1L;
428     JList<String> list;
429
430     RecentlyOpened()
431     {
432       setPreferredSize(new Dimension(300,100));
433       String historyItems = Cache.getProperty("RECENT_FILE");
434       StringTokenizer st;
435       Vector<String> recent = new Vector<>();
436
437       if (historyItems != null)
438       {
439         st = new StringTokenizer(historyItems, "\t");
440
441         while (st.hasMoreTokens())
442         {
443           recent.addElement(st.nextToken());
444         }
445       }
446
447       list = new JList<>(recent);
448   
449       DefaultListCellRenderer dlcr = new DefaultListCellRenderer();
450 //      dlcr.setHorizontalAlignment(DefaultListCellRenderer.RIGHT);
451       list.setCellRenderer(dlcr);
452
453       list.addMouseListener(new MouseAdapter()
454       {
455         @Override
456         public void mousePressed(MouseEvent evt)
457         {
458           recentListSelectionChanged(list.getSelectedValue());
459         }
460       });
461
462       this.setBorder(new javax.swing.border.TitledBorder(
463               MessageManager.getString("label.recently_opened")));
464
465       final JScrollPane scroller = new JScrollPane(list);
466
467       SpringLayout layout = new SpringLayout();
468       layout.putConstraint(SpringLayout.WEST, scroller, 5,
469               SpringLayout.WEST, this);
470       layout.putConstraint(SpringLayout.NORTH, scroller, 5,
471               SpringLayout.NORTH, this);
472
473       if (Platform.isAMacAndNotJS())
474       {
475         scroller.setPreferredSize(new Dimension(500, 100));
476       }
477       else
478       {
479         scroller.setPreferredSize(new Dimension(530, 200));
480       }
481
482       this.add(scroller);
483
484       javax.swing.SwingUtilities.invokeLater(new Runnable()
485       {
486         @Override
487         public void run()
488         {
489           scroller.getHorizontalScrollBar()
490                   .setValue(scroller.getHorizontalScrollBar().getMaximum());
491         }
492       });
493
494     }
495
496   }
497
498   @Override
499   public DialogRunnerI setResponseHandler(Object response, Runnable action)
500   {
501         callbacks.put(response,  action);
502         return this;
503   }
504
505   @Override
506   public void handleResponse(Object response)
507   {
508     /*
509          * this test is for NaN in Chrome
510          */
511     if (response != null && !response.equals(response))
512     {
513       return;
514     }
515     Runnable action = callbacks.get(response);
516     if (action != null)
517     {
518       action.run();
519     }
520   }
521
522   /**
523    * JalviewJS signals file selection by a property change event
524    * for property "SelectedFile".  This methods responds to that by
525    * running the response action for 'OK' in the dialog.
526    * 
527    * @param evt
528    */
529   @Override
530   public void propertyChange(PropertyChangeEvent evt)
531   {
532     // TODO other properties need runners...
533     switch (evt.getPropertyName())
534     {
535     /*
536      * property name here matches that used in JFileChooser.js
537      */
538     case "SelectedFile": 
539       handleResponse(APPROVE_OPTION);
540       break;
541     }
542   }
543 }