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