JAL-3551 save structure viewer session refactorings, PyMol added
[jalview.git] / src / jalview / ext / rbvi / chimera / JalviewChimeraBinding.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 package jalview.ext.rbvi.chimera;
22
23 import java.io.File;
24 import java.io.FileOutputStream;
25 import java.io.IOException;
26 import java.io.PrintWriter;
27 import java.net.BindException;
28 import java.util.ArrayList;
29 import java.util.Collections;
30 import java.util.Iterator;
31 import java.util.LinkedHashMap;
32 import java.util.List;
33 import java.util.Map;
34
35 import ext.edu.ucsf.rbvi.strucviz2.ChimeraManager;
36 import ext.edu.ucsf.rbvi.strucviz2.ChimeraModel;
37 import ext.edu.ucsf.rbvi.strucviz2.StructureManager;
38 import ext.edu.ucsf.rbvi.strucviz2.StructureManager.ModelType;
39 import jalview.api.AlignmentViewPanel;
40 import jalview.api.structures.JalviewStructureDisplayI;
41 import jalview.bin.Cache;
42 import jalview.datamodel.AlignmentI;
43 import jalview.datamodel.PDBEntry;
44 import jalview.datamodel.SearchResultMatchI;
45 import jalview.datamodel.SearchResultsI;
46 import jalview.datamodel.SequenceFeature;
47 import jalview.datamodel.SequenceI;
48 import jalview.gui.StructureViewer.ViewerType;
49 import jalview.httpserver.AbstractRequestHandler;
50 import jalview.io.DataSourceType;
51 import jalview.structure.AtomSpec;
52 import jalview.structure.StructureCommand;
53 import jalview.structure.StructureCommandI;
54 import jalview.structure.StructureSelectionManager;
55 import jalview.structures.models.AAStructureBindingModel;
56
57 public abstract class JalviewChimeraBinding extends AAStructureBindingModel
58 {
59   public static final String CHIMERA_FEATURE_GROUP = "Chimera";
60
61   // Chimera clause to exclude alternate locations in atom selection
62   private static final String NO_ALTLOCS = "&~@.B-Z&~@.2-9";
63
64   private static final boolean debug = false;
65
66   private static final String PHOSPHORUS = "P";
67
68   private static final String ALPHACARBON = "CA";
69
70   /*
71    * Object through which we talk to Chimera
72    */
73   private ChimeraManager chimeraManager;
74
75   /*
76    * Object which listens to Chimera notifications
77    */
78   private AbstractRequestHandler chimeraListener;
79
80   /*
81    * Map of ChimeraModel objects keyed by PDB full local file name
82    */
83   protected Map<String, List<ChimeraModel>> chimeraMaps = new LinkedHashMap<>();
84
85   String lastHighlightCommand;
86
87   private Thread chimeraMonitor;
88
89   /**
90    * Open a PDB structure file in Chimera and set up mappings from Jalview.
91    * 
92    * We check if the PDB model id is already loaded in Chimera, if so don't reopen
93    * it. This is the case if Chimera has opened a saved session file.
94    * 
95    * @param pe
96    * @return
97    */
98   public boolean openFile(PDBEntry pe)
99   {
100     String file = pe.getFile();
101     try
102     {
103       List<ChimeraModel> modelsToMap = new ArrayList<>();
104       List<ChimeraModel> oldList = chimeraManager.getModelList();
105       boolean alreadyOpen = false;
106
107       /*
108        * If Chimera already has this model, don't reopen it, but do remap it.
109        */
110       for (ChimeraModel open : oldList)
111       {
112         if (open.getModelName().equals(pe.getId()))
113         {
114           alreadyOpen = true;
115           modelsToMap.add(open);
116         }
117       }
118
119       /*
120        * If Chimera doesn't yet have this model, ask it to open it, and retrieve
121        * the model name(s) added by Chimera.
122        */
123       if (!alreadyOpen)
124       {
125         chimeraManager.openModel(file, pe.getId(), ModelType.PDB_MODEL);
126         addChimeraModel(pe, modelsToMap);
127       }
128
129       chimeraMaps.put(file, modelsToMap);
130
131       if (getSsm() != null)
132       {
133         getSsm().addStructureViewerListener(this);
134       }
135       return true;
136     } catch (Exception q)
137     {
138       log("Exception when trying to open model " + file + "\n"
139               + q.toString());
140       q.printStackTrace();
141     }
142     return false;
143   }
144
145   /**
146    * Adds the ChimeraModel corresponding to the given PDBEntry, based on model
147    * name matching PDB id
148    * 
149    * @param pe
150    * @param modelsToMap
151    */
152   protected void addChimeraModel(PDBEntry pe,
153           List<ChimeraModel> modelsToMap)
154   {
155     /*
156      * Chimera: query for actual models and find the one with
157      * matching model name - already set in viewer.openModel()
158      */
159     List<ChimeraModel> newList = chimeraManager.getModelList();
160     // JAL-1728 newList.removeAll(oldList) does not work
161     for (ChimeraModel cm : newList)
162     {
163       if (cm.getModelName().equals(pe.getId()))
164       {
165         modelsToMap.add(cm);
166       }
167     }
168   }
169
170   /**
171    * Constructor
172    * 
173    * @param ssm
174    * @param pdbentry
175    * @param sequenceIs
176    * @param protocol
177    */
178   public JalviewChimeraBinding(StructureSelectionManager ssm,
179           PDBEntry[] pdbentry, SequenceI[][] sequenceIs,
180           DataSourceType protocol)
181   {
182     super(ssm, pdbentry, sequenceIs, protocol);
183     chimeraManager = new ChimeraManager(new StructureManager(true));
184     chimeraManager.setChimeraX(ViewerType.CHIMERAX.equals(getViewerType()));
185     setStructureCommands(new ChimeraCommands());
186   }
187
188   @Override
189   protected ViewerType getViewerType()
190   {
191     return ViewerType.CHIMERA;
192   }
193
194   /**
195    * Starts a thread that waits for the Chimera process to finish, so that we can
196    * then close the associated resources. This avoids leaving orphaned Chimera
197    * viewer panels in Jalview if the user closes Chimera.
198    */
199   protected void startChimeraProcessMonitor()
200   {
201     final Process p = chimeraManager.getChimeraProcess();
202     chimeraMonitor = new Thread(new Runnable()
203     {
204
205       @Override
206       public void run()
207       {
208         try
209         {
210           p.waitFor();
211           JalviewStructureDisplayI display = getViewer();
212           if (display != null)
213           {
214             display.closeViewer(false);
215           }
216         } catch (InterruptedException e)
217         {
218           // exit thread if Chimera Viewer is closed in Jalview
219         }
220       }
221     });
222     chimeraMonitor.start();
223   }
224
225   /**
226    * Start a dedicated HttpServer to listen for Chimera notifications, and tell it
227    * to start listening
228    */
229   public void startChimeraListener()
230   {
231     try
232     {
233       chimeraListener = new ChimeraListener(this);
234       chimeraManager.startListening(chimeraListener.getUri());
235     } catch (BindException e)
236     {
237       System.err.println(
238               "Failed to start Chimera listener: " + e.getMessage());
239     }
240   }
241
242   /**
243    * Close down the Jalview viewer and listener, and (optionally) the associated
244    * Chimera window.
245    */
246   public void closeViewer(boolean closeChimera)
247   {
248     getSsm().removeStructureViewerListener(this, this.getStructureFiles());
249     if (closeChimera)
250     {
251       chimeraManager.exitChimera();
252     }
253     if (this.chimeraListener != null)
254     {
255       chimeraListener.shutdown();
256       chimeraListener = null;
257     }
258     chimeraManager = null;
259
260     if (chimeraMonitor != null)
261     {
262       chimeraMonitor.interrupt();
263     }
264     releaseUIResources();
265   }
266
267   /**
268    * Helper method to construct model spec in Chimera format:
269    * <ul>
270    * <li>#0 (#1 etc) for a PDB file with no sub-models</li>
271    * <li>#0.1 (#1.1 etc) for a PDB file with sub-models</li>
272    * <ul>
273    * Note for now we only ever choose the first of multiple models. This
274    * corresponds to the hard-coded Jmol equivalent (compare {1.1}). Refactor in
275    * future if there is a need to select specific sub-models.
276    * 
277    * @param pdbfnum
278    * @return
279    */
280   protected String getModelSpec(int pdbfnum)
281   {
282     if (pdbfnum < 0 || pdbfnum >= getPdbCount())
283     {
284       return "#" + pdbfnum; // temp hack for ChimeraX
285     }
286
287     /*
288      * For now, the test for having sub-models is whether multiple Chimera
289      * models are mapped for the PDB file; the models are returned as a response
290      * to the Chimera command 'list models type molecule', see
291      * ChimeraManager.getModelList().
292      */
293     List<ChimeraModel> maps = chimeraMaps.get(getStructureFiles()[pdbfnum]);
294     boolean hasSubModels = maps != null && maps.size() > 1;
295     return "#" + String.valueOf(pdbfnum) + (hasSubModels ? ".1" : "");
296   }
297
298   /**
299    * Launch Chimera, unless an instance linked to this object is already
300    * running. Returns true if Chimera is successfully launched, or already
301    * running, else false.
302    * 
303    * @return
304    */
305   public boolean launchChimera()
306   {
307     if (chimeraManager.isChimeraLaunched())
308     {
309       return true;
310     }
311
312     boolean launched = chimeraManager.launchChimera(getChimeraPaths());
313     if (launched)
314     {
315       startChimeraProcessMonitor();
316     }
317     else
318     {
319       log("Failed to launch Chimera!");
320     }
321     return launched;
322   }
323
324   /**
325    * Returns a list of candidate paths to the Chimera program executable
326    * 
327    * @return
328    */
329   protected List<String> getChimeraPaths()
330   {
331     return StructureManager.getChimeraPaths(false);
332   }
333
334   /**
335    * Answers true if the Chimera process is still running, false if ended or not
336    * started.
337    * 
338    * @return
339    */
340   public boolean isChimeraRunning()
341   {
342     return chimeraManager.isChimeraLaunched();
343   }
344
345   /**
346    * Send a command to Chimera, and optionally log and return any responses.
347    * 
348    * @param command
349    * @param getResponse
350    */
351   @Override
352   public List<String> executeCommand(final StructureCommandI command,
353           boolean getResponse)
354   {
355     if (chimeraManager == null || command == null)
356     {
357       // ? thread running after viewer shut down
358       return null;
359     }
360     List<String> reply = null;
361     // trim command or it may never find a match in the replyLog!!
362     String cmd = command.getCommand().trim();
363     List<String> lastReply = chimeraManager
364             .sendChimeraCommand(cmd, getResponse);
365     if (getResponse)
366     {
367       reply = lastReply;
368       if (debug)
369       {
370         log("Response from command ('" + cmd + "') was:\n" + lastReply);
371       }
372     }
373
374     return reply;
375   }
376
377   @Override
378   public synchronized String[] getStructureFiles()
379   {
380     if (chimeraManager == null)
381     {
382       return new String[0];
383     }
384
385     return chimeraMaps.keySet()
386             .toArray(modelFileNames = new String[chimeraMaps.size()]);
387   }
388
389   /**
390    * Construct and send a command to highlight zero, one or more atoms. We do this
391    * by sending an "rlabel" command to show the residue label at that position.
392    */
393   @Override
394   public void highlightAtoms(List<AtomSpec> atoms)
395   {
396     if (atoms == null || atoms.size() == 0)
397     {
398       return;
399     }
400
401     boolean forChimeraX = chimeraManager.isChimeraX();
402     StringBuilder cmd = new StringBuilder(128);
403     boolean first = true;
404     boolean found = false;
405
406     for (AtomSpec atom : atoms)
407     {
408       int pdbResNum = atom.getPdbResNum();
409       String chain = atom.getChain();
410       String pdbfile = atom.getPdbFile();
411       List<ChimeraModel> cms = chimeraMaps.get(pdbfile);
412       if (cms != null && !cms.isEmpty())
413       {
414         if (first)
415         {
416           cmd.append(forChimeraX ? "label #" : "rlabel #");
417         }
418         else
419         {
420           cmd.append(",");
421         }
422         first = false;
423         if (forChimeraX)
424         {
425           cmd.append(cms.get(0).getModelNumber())
426                   .append("/").append(chain).append(":").append(pdbResNum);
427         }
428         else
429         {
430           cmd.append(cms.get(0).getModelNumber())
431                   .append(":").append(pdbResNum);
432           if (!chain.equals(" ") && !forChimeraX)
433           {
434             cmd.append(".").append(chain);
435           }
436         }
437         found = true;
438       }
439     }
440     String command = cmd.toString();
441
442     /*
443      * avoid repeated commands for the same residue
444      */
445     if (command.equals(lastHighlightCommand))
446     {
447       return;
448     }
449
450     /*
451      * unshow the label for the previous residue
452      */
453     if (lastHighlightCommand != null)
454     {
455       chimeraManager.sendChimeraCommand("~" + lastHighlightCommand, false);
456     }
457     if (found)
458     {
459       chimeraManager.sendChimeraCommand(command, false);
460     }
461     this.lastHighlightCommand = command;
462   }
463
464   /**
465    * Query Chimera for its current selection, and highlight it on the alignment
466    */
467   public void highlightChimeraSelection()
468   {
469     /*
470      * Ask Chimera for its current selection
471      */
472     List<String> selection = chimeraManager.getSelectedResidueSpecs();
473
474     /*
475      * Parse model number, residue and chain for each selected position,
476      * formatted as #0:123.A or #1.2:87.B (#model.submodel:residue.chain)
477      */
478     List<AtomSpec> atomSpecs = convertStructureResiduesToAlignment(
479             selection);
480
481     /*
482      * Broadcast the selection (which may be empty, if the user just cleared all
483      * selections)
484      */
485     getSsm().mouseOverStructure(atomSpecs);
486   }
487
488   /**
489    * Converts a list of Chimera atomspecs to a list of AtomSpec representing the
490    * corresponding residues (if any) in Jalview
491    * 
492    * @param structureSelection
493    * @return
494    */
495   protected List<AtomSpec> convertStructureResiduesToAlignment(
496           List<String> structureSelection)
497   {
498     boolean chimeraX = chimeraManager.isChimeraX();
499     List<AtomSpec> atomSpecs = new ArrayList<>();
500     for (String atomSpec : structureSelection)
501     {
502       try
503       {
504         AtomSpec spec = AtomSpec.fromChimeraAtomspec(atomSpec, chimeraX);
505         String pdbfilename = getPdbFileForModel(spec.getModelNumber());
506         spec.setPdbFile(pdbfilename);
507         atomSpecs.add(spec);
508       } catch (IllegalArgumentException e)
509       {
510         System.err.println("Failed to parse atomspec: " + atomSpec);
511       }
512     }
513     return atomSpecs;
514   }
515
516   /**
517    * @param modelId
518    * @return
519    */
520   protected String getPdbFileForModel(int modelId)
521   {
522     /*
523      * Work out the pdbfilename from the model number
524      */
525     String pdbfilename = modelFileNames[0];
526     findfileloop: for (String pdbfile : this.chimeraMaps.keySet())
527     {
528       for (ChimeraModel cm : chimeraMaps.get(pdbfile))
529       {
530         if (cm.getModelNumber() == modelId)
531         {
532           pdbfilename = pdbfile;
533           break findfileloop;
534         }
535       }
536     }
537     return pdbfilename;
538   }
539
540   private void log(String message)
541   {
542     System.err.println("## Chimera log: " + message);
543   }
544
545   /**
546    * Ask Chimera to save its session to the given file. Returns true if
547    * successful, else false.
548    * 
549    * @param filepath
550    * @return
551    */
552   public boolean saveSession(String filepath)
553   {
554     if (isChimeraRunning())
555     {
556       /*
557        * Chimera:  https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/save.html
558        * ChimeraX: https://www.cgl.ucsf.edu/chimerax/docs/user/commands/save.html
559        */
560       String command = getCommandGenerator().saveSession(filepath)
561               .getCommand();
562       List<String> reply = chimeraManager.sendChimeraCommand(command, true);
563       if (reply.contains("Session written"))
564       {
565         return true;
566       }
567       else
568       {
569         Cache.log
570                 .error("Error saving Chimera session: " + reply.toString());
571       }
572     }
573     return false;
574   }
575
576   /**
577    * Ask Chimera to open a session file. Returns true if successful, else false.
578    * The filename must have a .py (Chimera) or .cxs (ChimeraX) extension for
579    * this command to work.
580    * 
581    * @param filepath
582    * @return
583    */
584   public boolean openSession(String filepath)
585   {
586     /*
587      * Chimera:  https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/open.html
588      * ChimeraX: https://www.cgl.ucsf.edu/chimerax/docs/user/commands/open.html
589      */
590     executeCommand(getCommandGenerator().loadFile(filepath), true);
591     // todo: test for failure - how?
592     return true;
593   }
594
595   /**
596    * Send a 'show' command for all atoms in the currently selected columns
597    * 
598    * TODO: pull up to abstract structure viewer interface
599    * 
600    * @param vp
601    */
602   public void highlightSelection(AlignmentViewPanel vp)
603   {
604     List<Integer> cols = vp.getAlignViewport().getColumnSelection()
605             .getSelected();
606     AlignmentI alignment = vp.getAlignment();
607     StructureSelectionManager sm = getSsm();
608     for (SequenceI seq : alignment.getSequences())
609     {
610       /*
611        * convert selected columns into sequence positions
612        */
613       int[] positions = new int[cols.size()];
614       int i = 0;
615       for (Integer col : cols)
616       {
617         positions[i++] = seq.findPosition(col);
618       }
619       sm.highlightStructure(this, seq, positions);
620     }
621   }
622
623   /**
624    * Constructs and send commands to Chimera to set attributes on residues for
625    * features visible in Jalview
626    * 
627    * @param avp
628    * @return
629    */
630   public int sendFeaturesToViewer(AlignmentViewPanel avp)
631   {
632     // TODO refactor as required to pull up to an interface
633     String[] files = getStructureFiles();
634     if (files == null)
635     {
636       return 0;
637     }
638
639     List<StructureCommandI> commands = getCommandGenerator()
640             .setAttributesForFeatures(getSsm(), files, getSequence(), avp);
641     if (commands.size() > 10)
642     {
643       sendCommandsByFile(commands);
644     }
645     else
646     {
647       for (StructureCommandI command : commands)
648       {
649         sendAsynchronousCommand(command, null);
650       }
651     }
652     return commands.size();
653   }
654
655   /**
656    * Write commands to a temporary file, and send a command to Chimera to open the
657    * file as a commands script. For use when sending a large number of separate
658    * commands would overload the REST interface mechanism.
659    * 
660    * @param commands
661    */
662   protected void sendCommandsByFile(List<StructureCommandI> commands)
663   {
664     try
665     {
666       File tmp = File.createTempFile("chim", getCommandFileExtension());
667       tmp.deleteOnExit();
668       PrintWriter out = new PrintWriter(new FileOutputStream(tmp));
669       for (StructureCommandI command : commands)
670       {
671         out.println(command.getCommand());
672       }
673       out.flush();
674       out.close();
675       String path = tmp.getAbsolutePath();
676       StructureCommandI command = getCommandGenerator()
677               .openCommandFile(path);
678       sendAsynchronousCommand(command, null);
679     } catch (IOException e)
680     {
681       System.err.println("Sending commands to Chimera via file failed with "
682               + e.getMessage());
683     }
684   }
685
686   /**
687    * Returns the file extension required for a file of commands to be read by
688    * the structure viewer
689    * @return
690    */
691   protected String getCommandFileExtension()
692   {
693     return ".com";
694   }
695
696   /**
697    * Get Chimera residues which have the named attribute, find the mapped
698    * positions in the Jalview sequence(s), and set as sequence features
699    * 
700    * @param attName
701    * @param alignmentPanel
702    */
703   public void copyStructureAttributesToFeatures(String attName,
704           AlignmentViewPanel alignmentPanel)
705   {
706     // todo pull up to AAStructureBindingModel (and interface?)
707
708     /*
709      * ask Chimera to list residues with the attribute, reporting its value
710      */
711     // this alternative command
712     // list residues spec ':*/attName' attr attName
713     // doesn't report 'None' values (which is good), but
714     // fails for 'average.bfactor' (which is bad):
715
716     String cmd = "list residues attr '" + attName + "'";
717     List<String> residues = executeCommand(new StructureCommand(cmd), true);
718
719     boolean featureAdded = createFeaturesForAttributes(attName, residues);
720     if (featureAdded)
721     {
722       alignmentPanel.getFeatureRenderer().featuresAdded();
723     }
724   }
725
726   /**
727    * Create features in Jalview for the given attribute name and structure
728    * residues.
729    * 
730    * <pre>
731    * The residue list should be 0, 1 or more reply lines of the format: 
732    *     residue id #0:5.A isHelix -155.000836316 index 5 
733    * or 
734    *     residue id #0:6.A isHelix None
735    * </pre>
736    * 
737    * @param attName
738    * @param residues
739    * @return
740    */
741   protected boolean createFeaturesForAttributes(String attName,
742           List<String> residues)
743   {
744     boolean featureAdded = false;
745     String featureGroup = getViewerFeatureGroup();
746     boolean chimeraX = chimeraManager.isChimeraX();
747
748     for (String residue : residues)
749     {
750       AtomSpec spec = null;
751       String[] tokens = residue.split(" ");
752       if (tokens.length < 5)
753       {
754         continue;
755       }
756       String atomSpec = tokens[2];
757       String attValue = tokens[4];
758
759       /*
760        * ignore 'None' (e.g. for phi) or 'False' (e.g. for isHelix)
761        */
762       if ("None".equalsIgnoreCase(attValue)
763               || "False".equalsIgnoreCase(attValue))
764       {
765         continue;
766       }
767
768       try
769       {
770         spec = AtomSpec.fromChimeraAtomspec(atomSpec, chimeraX);
771       } catch (IllegalArgumentException e)
772       {
773         System.err.println("Problem parsing atomspec " + atomSpec);
774         continue;
775       }
776
777       String chainId = spec.getChain();
778       String description = attValue;
779       float score = Float.NaN;
780       try
781       {
782         score = Float.valueOf(attValue);
783         description = chainId;
784       } catch (NumberFormatException e)
785       {
786         // was not a float value
787       }
788
789       String pdbFile = getPdbFileForModel(spec.getModelNumber());
790       spec.setPdbFile(pdbFile);
791
792       List<AtomSpec> atoms = Collections.singletonList(spec);
793
794       /*
795        * locate the mapped position in the alignment (if any)
796        */
797       SearchResultsI sr = getSsm()
798               .findAlignmentPositionsForStructurePositions(atoms);
799
800       /*
801        * expect one matched alignment position, or none 
802        * (if the structure position is not mapped)
803        */
804       for (SearchResultMatchI m : sr.getResults())
805       {
806         SequenceI seq = m.getSequence();
807         int start = m.getStart();
808         int end = m.getEnd();
809         SequenceFeature sf = new SequenceFeature(attName, description,
810                 start, end, score, featureGroup);
811         // todo: should SequenceFeature have an explicit property for chain?
812         // note: repeating the action shouldn't duplicate features
813         featureAdded |= seq.addSequenceFeature(sf);
814       }
815     }
816     return featureAdded;
817   }
818
819   /**
820    * Answers the feature group name to apply to features created in Jalview from
821    * Chimera attributes
822    * 
823    * @return
824    */
825   protected String getViewerFeatureGroup()
826   {
827     // todo pull up to interface
828     return CHIMERA_FEATURE_GROUP;
829   }
830
831   @Override
832   public String getModelIdForFile(String pdbFile)
833   {
834     List<ChimeraModel> foundModels = chimeraMaps.get(pdbFile);
835     if (foundModels != null && !foundModels.isEmpty())
836     {
837       return String.valueOf(foundModels.get(0).getModelNumber());
838     }
839     return "";
840   }
841
842   /**
843    * Answers a (possibly empty) list of attribute names in Chimera[X], excluding
844    * any which were added from Jalview
845    * 
846    * @return
847    */
848   public List<String> getChimeraAttributes()
849   {
850     List<String> atts = chimeraManager.getAttrList();
851     Iterator<String> it = atts.iterator();
852     while (it.hasNext())
853     {
854       if (it.next().startsWith(ChimeraCommands.NAMESPACE_PREFIX))
855       {
856         /*
857          * attribute added from Jalview - exclude it
858          */
859         it.remove();
860       }
861     }
862     return atts;
863   }
864
865   /**
866    * Returns the file extension to use for a saved viewer session file (.py)
867    * 
868    * @return
869    */
870   @Override
871   public String getSessionFileExtension()
872   {
873     return ".py";
874   }
875
876   public String getHelpURL()
877   {
878     return "https://www.cgl.ucsf.edu/chimera/docs/UsersGuide";
879   }
880 }