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