source: sasview/src/sas/qtgui/MainWindow/GuiManager.py @ 28a09b0

ESS_GUIESS_GUI_DocsESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_iss959ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since 28a09b0 was 28a09b0, checked in by Piotr Rozyczko <rozyczko@…>, 7 years ago

Merged Celine's implementation of the generic scattering calculator

  • Property mode set to 100644
File size: 24.2 KB
Line 
1import sys
2import os
3import subprocess
4import logging
5import json
6import webbrowser
7
8from PyQt4 import QtCore
9from PyQt4 import QtGui
10from PyQt4 import QtWebKit
11
12from twisted.internet import reactor
13# General SAS imports
14
15from sas.sasgui.guiframe.data_manager import DataManager
16from sas.sasgui.guiframe.proxy import Connection
17from sas.qtgui.Utilities.SasviewLogger import XStream
18from sas.qtgui.Utilities.IPythonWidget import IPythonWidget
19import sas.qtgui.Utilities.LocalConfig as LocalConfig
20import sas.qtgui.Utilities.GuiUtils as GuiUtils
21
22from sas.qtgui.MainWindow.UI.AcknowledgementsUI import Ui_Acknowledgements
23from sas.qtgui.MainWindow.AboutBox import AboutBox
24from sas.qtgui.MainWindow.WelcomePanel import WelcomePanel
25
26from sas.qtgui.Calculators.SldPanel import SldPanel
27from sas.qtgui.Calculators.DensityPanel import DensityPanel
28from sas.qtgui.Calculators.KiessigPanel import KiessigPanel
29from sas.qtgui.Calculators.SlitSizeCalculator import SlitSizeCalculator
30from sas.qtgui.Calculators.GenericScatteringCalculator import GenericScatteringCalculator
31
32# Perspectives
33import sas.qtgui.Perspectives as Perspectives
34from sas.qtgui.Perspectives.Fitting.FittingPerspective import FittingWindow
35from sas.qtgui.MainWindow.DataExplorer import DataExplorerWindow
36
37class Acknowledgements(QtGui.QDialog, Ui_Acknowledgements):
38    def __init__(self, parent=None):
39        QtGui.QDialog.__init__(self, parent)
40        self.setupUi(self)
41
42class GuiManager(object):
43    """
44    Main SasView window functionality
45    """
46    ## TODO: CHANGE FOR SHIPPED PATH IN RELEASE
47    HELP_DIRECTORY_LOCATION = "docs/sphinx-docs/build/html"
48
49    def __init__(self, parent=None):
50        """
51        Initialize the manager as a child of MainWindow.
52        """
53        self._workspace = parent
54        self._parent = parent
55
56        # Add signal callbacks
57        self.addCallbacks()
58
59        # Create the data manager
60        # TODO: pull out all required methods from DataManager and reimplement
61        self._data_manager = DataManager()
62
63        # Create action triggers
64        self.addTriggers()
65
66        # Populate menus with dynamic data
67        #
68        # Analysis/Perspectives - potentially
69        # Window/current windows
70        #
71        # Widgets
72        #
73        # Current displayed perspective
74        self._current_perspective = None
75
76        # Invoke the initial perspective
77        self.perspectiveChanged("Fitting")
78
79        self.addWidgets()
80
81        # Fork off logging messages to the Log Window
82        XStream.stdout().messageWritten.connect(self.listWidget.insertPlainText)
83        XStream.stderr().messageWritten.connect(self.listWidget.insertPlainText)
84
85        # Log the start of the session
86        logging.info(" --- SasView session started ---")
87        # Log the python version
88        logging.info("Python: %s" % sys.version)
89
90        # Set up the status bar
91        self.statusBarSetup()
92
93        # Show the Welcome panel
94        self.welcomePanel = WelcomePanel()
95        self._workspace.workspace.addWindow(self.welcomePanel)
96
97        # Current help file
98        self._helpView = QtWebKit.QWebView()
99        # Needs URL like path, so no path.join() here
100        self._helpLocation = self.HELP_DIRECTORY_LOCATION + "/index.html"
101
102        # Current tutorial location
103        self._tutorialLocation = os.path.abspath(os.path.join(self.HELP_DIRECTORY_LOCATION,
104                                              "_downloads",
105                                              "Tutorial.pdf"))
106    def addWidgets(self):
107        """
108        Populate the main window with widgets
109
110        TODO: overwrite close() on Log and DR widgets so they can be hidden/shown
111        on request
112        """
113        # Add FileDialog widget as docked
114        self.filesWidget = DataExplorerWindow(self._parent, self, manager=self._data_manager)
115
116        self.dockedFilesWidget = QtGui.QDockWidget("Data Explorer", self._workspace)
117        self.dockedFilesWidget.setWidget(self.filesWidget)
118
119        # Disable maximize/minimize and close buttons
120        self.dockedFilesWidget.setFeatures(QtGui.QDockWidget.NoDockWidgetFeatures)
121        self._workspace.addDockWidget(QtCore.Qt.LeftDockWidgetArea,
122                                      self.dockedFilesWidget)
123
124        # Add the console window as another docked widget
125        self.logDockWidget = QtGui.QDockWidget("Log Explorer", self._workspace)
126        self.logDockWidget.setObjectName("LogDockWidget")
127        self.listWidget = QtGui.QTextBrowser()
128        self.logDockWidget.setWidget(self.listWidget)
129        self._workspace.addDockWidget(QtCore.Qt.BottomDockWidgetArea,
130                                      self.logDockWidget)
131
132        # Add other, minor widgets
133        self.ackWidget = Acknowledgements()
134        self.aboutWidget = AboutBox()
135
136        # Add calculators - floating for usability
137        self.SLDCalculator = SldPanel(self)
138        self.DVCalculator = DensityPanel(self)
139        #self.KIESSIGCalculator = DensityPanel(self)#KiessigPanel(self)
140        self.KIESSIGCalculator = KiessigPanel(self)
141        self.SlitSizeCalculator = SlitSizeCalculator(self)
142        self.GENSASCalculator = GenericScatteringCalculator(self)
143
144    def statusBarSetup(self):
145        """
146        Define the status bar.
147        | <message label> .... | Progress Bar |
148
149        Progress bar invisible until explicitly shown
150        """
151        self.progress = QtGui.QProgressBar()
152        self._workspace.statusbar.setSizeGripEnabled(False)
153
154        self.statusLabel = QtGui.QLabel()
155        self.statusLabel.setText("Welcome to SasView")
156        self._workspace.statusbar.addPermanentWidget(self.statusLabel, 1)
157        self._workspace.statusbar.addPermanentWidget(self.progress, stretch=0)
158        self.progress.setRange(0, 100)
159        self.progress.setValue(0)
160        self.progress.setTextVisible(True)
161        self.progress.setVisible(False)
162
163    def fileWasRead(self, data):
164        """
165        Callback for fileDataReceivedSignal
166        """
167        pass
168
169    def workspace(self):
170        """
171        Accessor for the main window workspace
172        """
173        return self._workspace.workspace
174
175    def perspectiveChanged(self, perspective_name):
176        """
177        Respond to change of the perspective signal
178        """
179        # Close the previous perspective
180        if self._current_perspective:
181            self._current_perspective.setClosable()
182            self._current_perspective.close()
183        # Default perspective
184        self._current_perspective = Perspectives.PERSPECTIVES[str(perspective_name)](parent=self)
185
186        self._workspace.workspace.addWindow(self._current_perspective)
187        # Resize to the workspace height
188        workspace_height = self._workspace.workspace.sizeHint().height()
189        perspective_size = self._current_perspective.sizeHint()
190        if workspace_height < perspective_size.height:
191            perspective_width = perspective_size.width()
192            self._current_perspective.resize(perspective_width, workspace_height-10)
193        self._current_perspective.show()
194
195    def updatePerspective(self, data):
196        """
197        Update perspective with data sent.
198        """
199        assert isinstance(data, list)
200        if self._current_perspective is not None:
201            self._current_perspective.setData(data.values())
202        else:
203            msg = "No perspective is currently active."
204            logging.info(msg)
205
206    def communicator(self):
207        """ Accessor for the communicator """
208        return self.communicate
209
210    def perspective(self):
211        """ Accessor for the perspective """
212        return self._current_perspective
213
214    def updateProgressBar(self, value):
215        """
216        Update progress bar with the required value (0-100)
217        """
218        assert -1 <= value <= 100
219        if value == -1:
220            self.progress.setVisible(False)
221            return
222        if not self.progress.isVisible():
223            self.progress.setTextVisible(True)
224            self.progress.setVisible(True)
225
226        self.progress.setValue(value)
227
228    def updateStatusBar(self, text):
229        """
230        Set the status bar text
231        """
232        self.statusLabel.setText(text)
233
234    def createGuiData(self, item, p_file=None):
235        """
236        Access the Data1D -> plottable Data1D conversion
237        """
238        return self._data_manager.create_gui_data(item, p_file)
239
240    def setData(self, data):
241        """
242        Sends data to current perspective
243        """
244        if self._current_perspective is not None:
245            self._current_perspective.setData(data.values())
246        else:
247            msg = "Guiframe does not have a current perspective"
248            logging.info(msg)
249
250    def quitApplication(self):
251        """
252        Close the reactor and exit nicely.
253        """
254        # Display confirmation messagebox
255        quit_msg = "Are you sure you want to exit the application?"
256        reply = QtGui.QMessageBox.question(
257            self._parent,
258            'Information',
259            quit_msg,
260            QtGui.QMessageBox.Yes,
261            QtGui.QMessageBox.No)
262
263        # Exit if yes
264        if reply == QtGui.QMessageBox.Yes:
265            reactor.callFromThread(reactor.stop)
266            return True
267
268        return False
269
270    def checkUpdate(self):
271        """
272        Check with the deployment server whether a new version
273        of the application is available.
274        A thread is started for the connecting with the server. The thread calls
275        a call-back method when the current version number has been obtained.
276        """
277        version_info = {"version": "0.0.0"}
278        c = Connection(LocalConfig.__update_URL__, LocalConfig.UPDATE_TIMEOUT)
279        response = c.connect()
280        if response is None:
281            return
282        try:
283            content = response.read().strip()
284            logging.info("Connected to www.sasview.org. Latest version: %s"
285                            % (content))
286            version_info = json.loads(content)
287            self.processVersion(version_info)
288        except ValueError, ex:
289            logging.info("Failed to connect to www.sasview.org:", ex)
290
291    def processVersion(self, version_info):
292        """
293        Call-back method for the process of checking for updates.
294        This methods is called by a VersionThread object once the current
295        version number has been obtained. If the check is being done in the
296        background, the user will not be notified unless there's an update.
297
298        :param version: version string
299        """
300        try:
301            version = version_info["version"]
302            if version == "0.0.0":
303                msg = "Could not connect to the application server."
304                msg += " Please try again later."
305                self.communicate.statusBarUpdateSignal.emit(msg)
306
307            elif cmp(version, LocalConfig.__version__) > 0:
308                msg = "Version %s is available! " % str(version)
309                if "download_url" in version_info:
310                    webbrowser.open(version_info["download_url"])
311                else:
312                    webbrowser.open(LocalConfig.__download_page__)
313                self.communicate.statusBarUpdateSignal.emit(msg)
314            else:
315                msg = "You have the latest version"
316                msg += " of %s" % str(LocalConfig.__appname__)
317                self.communicate.statusBarUpdateSignal.emit(msg)
318        except:
319            msg = "guiframe: could not get latest application"
320            msg += " version number\n  %s" % sys.exc_value
321            logging.error(msg)
322            msg = "Could not connect to the application server."
323            msg += " Please try again later."
324            self.communicate.statusBarUpdateSignal.emit(msg)
325
326    def addCallbacks(self):
327        """
328        Method defining all signal connections for the gui manager
329        """
330        self.communicate = GuiUtils.Communicate()
331        self.communicate.fileDataReceivedSignal.connect(self.fileWasRead)
332        self.communicate.statusBarUpdateSignal.connect(self.updateStatusBar)
333        self.communicate.updatePerspectiveWithDataSignal.connect(self.updatePerspective)
334        self.communicate.progressBarUpdateSignal.connect(self.updateProgressBar)
335        self.communicate.perspectiveChangedSignal.connect(self.perspectiveChanged)
336        self.communicate.updateTheoryFromPerspectiveSignal.connect(self.updateTheoryFromPerspective)
337
338    def addTriggers(self):
339        """
340        Trigger definitions for all menu/toolbar actions.
341        """
342        # File
343        self._workspace.actionLoadData.triggered.connect(self.actionLoadData)
344        self._workspace.actionLoad_Data_Folder.triggered.connect(self.actionLoad_Data_Folder)
345        self._workspace.actionOpen_Project.triggered.connect(self.actionOpen_Project)
346        self._workspace.actionOpen_Analysis.triggered.connect(self.actionOpen_Analysis)
347        self._workspace.actionSave.triggered.connect(self.actionSave)
348        self._workspace.actionSave_Analysis.triggered.connect(self.actionSave_Analysis)
349        self._workspace.actionQuit.triggered.connect(self.actionQuit)
350        # Edit
351        self._workspace.actionUndo.triggered.connect(self.actionUndo)
352        self._workspace.actionRedo.triggered.connect(self.actionRedo)
353        self._workspace.actionCopy.triggered.connect(self.actionCopy)
354        self._workspace.actionPaste.triggered.connect(self.actionPaste)
355        self._workspace.actionReport.triggered.connect(self.actionReport)
356        self._workspace.actionReset.triggered.connect(self.actionReset)
357        self._workspace.actionExcel.triggered.connect(self.actionExcel)
358        self._workspace.actionLatex.triggered.connect(self.actionLatex)
359
360        # View
361        self._workspace.actionShow_Grid_Window.triggered.connect(self.actionShow_Grid_Window)
362        self._workspace.actionHide_Toolbar.triggered.connect(self.actionHide_Toolbar)
363        self._workspace.actionStartup_Settings.triggered.connect(self.actionStartup_Settings)
364        self._workspace.actionCategry_Manager.triggered.connect(self.actionCategry_Manager)
365        # Tools
366        self._workspace.actionData_Operation.triggered.connect(self.actionData_Operation)
367        self._workspace.actionSLD_Calculator.triggered.connect(self.actionSLD_Calculator)
368        self._workspace.actionDensity_Volume_Calculator.triggered.connect(self.actionDensity_Volume_Calculator)
369        self._workspace.actionKeissig_Calculator.triggered.connect(self.actionKiessig_Calculator)
370        #self._workspace.actionKIESSING_Calculator.triggered.connect(self.actionKIESSING_Calculator)
371        self._workspace.actionSlit_Size_Calculator.triggered.connect(self.actionSlit_Size_Calculator)
372        self._workspace.actionSAS_Resolution_Estimator.triggered.connect(self.actionSAS_Resolution_Estimator)
373        self._workspace.actionGeneric_Scattering_Calculator.triggered.connect(self.actionGeneric_Scattering_Calculator)
374        self._workspace.actionPython_Shell_Editor.triggered.connect(self.actionPython_Shell_Editor)
375        self._workspace.actionImage_Viewer.triggered.connect(self.actionImage_Viewer)
376        # Fitting
377        self._workspace.actionNew_Fit_Page.triggered.connect(self.actionNew_Fit_Page)
378        self._workspace.actionConstrained_Fit.triggered.connect(self.actionConstrained_Fit)
379        self._workspace.actionCombine_Batch_Fit.triggered.connect(self.actionCombine_Batch_Fit)
380        self._workspace.actionFit_Options.triggered.connect(self.actionFit_Options)
381        self._workspace.actionFit_Results.triggered.connect(self.actionFit_Results)
382        self._workspace.actionChain_Fitting.triggered.connect(self.actionChain_Fitting)
383        self._workspace.actionEdit_Custom_Model.triggered.connect(self.actionEdit_Custom_Model)
384        # Window
385        self._workspace.actionCascade.triggered.connect(self.actionCascade)
386        self._workspace.actionTile.triggered.connect(self.actionTile)
387        self._workspace.actionArrange_Icons.triggered.connect(self.actionArrange_Icons)
388        self._workspace.actionNext.triggered.connect(self.actionNext)
389        self._workspace.actionPrevious.triggered.connect(self.actionPrevious)
390        # Analysis
391        self._workspace.actionFitting.triggered.connect(self.actionFitting)
392        self._workspace.actionInversion.triggered.connect(self.actionInversion)
393        self._workspace.actionInvariant.triggered.connect(self.actionInvariant)
394        # Help
395        self._workspace.actionDocumentation.triggered.connect(self.actionDocumentation)
396        self._workspace.actionTutorial.triggered.connect(self.actionTutorial)
397        self._workspace.actionAcknowledge.triggered.connect(self.actionAcknowledge)
398        self._workspace.actionAbout.triggered.connect(self.actionAbout)
399        self._workspace.actionCheck_for_update.triggered.connect(self.actionCheck_for_update)
400
401    #============ FILE =================
402    def actionLoadData(self):
403        """
404        Menu File/Load Data File(s)
405        """
406        self.filesWidget.loadFile()
407
408    def actionLoad_Data_Folder(self):
409        """
410        Menu File/Load Data Folder
411        """
412        self.filesWidget.loadFolder()
413
414    def actionOpen_Project(self):
415        """
416        Menu Open Project
417        """
418        self.filesWidget.loadProject()
419
420    def actionOpen_Analysis(self):
421        """
422        """
423        print("actionOpen_Analysis TRIGGERED")
424        pass
425
426    def actionSave(self):
427        """
428        Menu Save Project
429        """
430        self.filesWidget.saveProject()
431
432    def actionSave_Analysis(self):
433        """
434        """
435        print("actionSave_Analysis TRIGGERED")
436
437        pass
438
439    def actionQuit(self):
440        """
441        Close the reactor, exit the application.
442        """
443        self.quitApplication()
444
445    #============ EDIT =================
446    def actionUndo(self):
447        """
448        """
449        print("actionUndo TRIGGERED")
450        pass
451
452    def actionRedo(self):
453        """
454        """
455        print("actionRedo TRIGGERED")
456        pass
457
458    def actionCopy(self):
459        """
460        """
461        print("actionCopy TRIGGERED")
462        pass
463
464    def actionPaste(self):
465        """
466        """
467        print("actionPaste TRIGGERED")
468        pass
469
470    def actionReport(self):
471        """
472        """
473        print("actionReport TRIGGERED")
474        pass
475
476    def actionReset(self):
477        """
478        """
479        logging.warning(" *** actionOpen_Analysis logging *******")
480        print("actionReset print TRIGGERED")
481        sys.stderr.write("STDERR - TRIGGERED")
482        pass
483
484    def actionExcel(self):
485        """
486        """
487        print("actionExcel TRIGGERED")
488        pass
489
490    def actionLatex(self):
491        """
492        """
493        print("actionLatex TRIGGERED")
494        pass
495
496    #============ VIEW =================
497    def actionShow_Grid_Window(self):
498        """
499        """
500        print("actionShow_Grid_Window TRIGGERED")
501        pass
502
503    def actionHide_Toolbar(self):
504        """
505        Toggle toolbar vsibility
506        """
507        if self._workspace.toolBar.isVisible():
508            self._workspace.actionHide_Toolbar.setText("Show Toolbar")
509            self._workspace.toolBar.setVisible(False)
510        else:
511            self._workspace.actionHide_Toolbar.setText("Hide Toolbar")
512            self._workspace.toolBar.setVisible(True)
513        pass
514
515    def actionStartup_Settings(self):
516        """
517        """
518        print("actionStartup_Settings TRIGGERED")
519        pass
520
521    def actionCategry_Manager(self):
522        """
523        """
524        print("actionCategry_Manager TRIGGERED")
525        pass
526
527    #============ TOOLS =================
528    def actionData_Operation(self):
529        """
530        """
531        print("actionData_Operation TRIGGERED")
532        pass
533
534    def actionSLD_Calculator(self):
535        """
536        """
537        self.SLDCalculator.show()
538
539    def actionDensity_Volume_Calculator(self):
540        """
541        """
542        self.DVCalculator.show()
543
544    def actionKiessig_Calculator(self):
545        """
546        """
547        #self.DVCalculator.show()
548        self.KIESSIGCalculator.show()
549
550    def actionSlit_Size_Calculator(self):
551        """
552        """
553        self.SlitSizeCalculator.show()
554
555    def actionSAS_Resolution_Estimator(self):
556        """
557        """
558        print("actionSAS_Resolution_Estimator TRIGGERED")
559        pass
560
561    def actionGeneric_Scattering_Calculator(self):
562        """
563        """
564        self.GENSASCalculator.show()
565
566    def actionPython_Shell_Editor(self):
567        """
568        Display the Jupyter console as a docked widget.
569        """
570        terminal = IPythonWidget()
571
572        # Add the console window as another docked widget
573        self.ipDockWidget = QtGui.QDockWidget("IPython", self._workspace)
574        self.ipDockWidget.setObjectName("IPythonDockWidget")
575        self.ipDockWidget.setWidget(terminal)
576        self._workspace.addDockWidget(QtCore.Qt.RightDockWidgetArea,
577                                      self.ipDockWidget)
578
579    def actionImage_Viewer(self):
580        """
581        """
582        print("actionImage_Viewer TRIGGERED")
583        pass
584
585    #============ FITTING =================
586    def actionNew_Fit_Page(self):
587        """
588        Add a new, empty Fit page in the fitting perspective.
589        """
590        # Make sure the perspective is correct
591        per = self.perspective()
592        if not isinstance(per, FittingWindow):
593            return
594        per.addFit(None)
595
596    def actionConstrained_Fit(self):
597        """
598        """
599        print("actionConstrained_Fit TRIGGERED")
600        pass
601
602    def actionCombine_Batch_Fit(self):
603        """
604        """
605        print("actionCombine_Batch_Fit TRIGGERED")
606        pass
607
608    def actionFit_Options(self):
609        """
610        """
611        print("actionFit_Options TRIGGERED")
612        pass
613
614    def actionFit_Results(self):
615        """
616        """
617        print("actionFit_Results TRIGGERED")
618        pass
619
620    def actionChain_Fitting(self):
621        """
622        """
623        print("actionChain_Fitting TRIGGERED")
624        pass
625
626    def actionEdit_Custom_Model(self):
627        """
628        """
629        print("actionEdit_Custom_Model TRIGGERED")
630        pass
631
632    #============ ANALYSIS =================
633    def actionFitting(self):
634        """
635        """
636        print("actionFitting TRIGGERED")
637        pass
638
639    def actionInversion(self):
640        """
641        """
642        print("actionInversion TRIGGERED")
643        pass
644
645    def actionInvariant(self):
646        """
647        """
648        print("actionInvariant TRIGGERED")
649        pass
650
651    #============ WINDOW =================
652    def actionCascade(self):
653        """
654        Arranges all the child windows in a cascade pattern.
655        """
656        self._workspace.workspace.cascade()
657
658    def actionTile(self):
659        """
660        Tile workspace windows
661        """
662        self._workspace.workspace.tile()
663
664    def actionArrange_Icons(self):
665        """
666        Arranges all iconified windows at the bottom of the workspace
667        """
668        self._workspace.workspace.arrangeIcons()
669
670    def actionNext(self):
671        """
672        Gives the input focus to the next window in the list of child windows.
673        """
674        self._workspace.workspace.activateNextWindow()
675
676    def actionPrevious(self):
677        """
678        Gives the input focus to the previous window in the list of child windows.
679        """
680        self._workspace.workspace.activatePreviousWindow()
681
682    #============ HELP =================
683    def actionDocumentation(self):
684        """
685        Display the documentation
686
687        TODO: use QNetworkAccessManager to assure _helpLocation is valid
688        """
689        self._helpView.load(QtCore.QUrl(self._helpLocation))
690        self._helpView.show()
691
692    def actionTutorial(self):
693        """
694        Open the tutorial PDF file with default PDF renderer
695        """
696        # Not terribly safe here. Shell injection warning.
697        # isfile() helps but this probably needs a better solution.
698        if os.path.isfile(self._tutorialLocation):
699            result = subprocess.Popen([self._tutorialLocation], shell=True)
700
701    def actionAcknowledge(self):
702        """
703        Open the Acknowledgements widget
704        """
705        self.ackWidget.show()
706
707    def actionAbout(self):
708        """
709        Open the About box
710        """
711        # Update the about box with current version and stuff
712
713        # TODO: proper sizing
714        self.aboutWidget.show()
715
716    def actionCheck_for_update(self):
717        """
718        Menu Help/Check for Update
719        """
720        self.checkUpdate()
721
722    def updateTheoryFromPerspective(self, index):
723        """
724        Catch the theory update signal from a perspective
725        Send the request to the DataExplorer for updating the theory model.
726        """
727        self.filesWidget.updateTheoryFromPerspective(index)
728
729
730
Note: See TracBrowser for help on using the repository browser.