source: sasview/src/sas/qtgui/MainWindow/DataExplorer.py @ 557fc498

ESS_GUIESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since 557fc498 was 33b3e4d, checked in by Piotr Rozyczko <rozyczko@…>, 6 years ago

Replace THEORIES in data tab with FIT RESULTS. SASVIEW-1055

  • Property mode set to 100644
File size: 50.6 KB
Line 
1# global
2import sys
3import os
4import time
5import logging
6
7from PyQt5 import QtCore
8from PyQt5 import QtGui
9from PyQt5 import QtWidgets
10
11from twisted.internet import threads
12
13# SASCALC
14from sas.sascalc.dataloader.loader import Loader
15
16# QTGUI
17import sas.qtgui.Utilities.GuiUtils as GuiUtils
18import sas.qtgui.Plotting.PlotHelper as PlotHelper
19
20from sas.qtgui.Plotting.PlotterData import Data1D
21from sas.qtgui.Plotting.PlotterData import Data2D
22from sas.qtgui.Plotting.Plotter import Plotter
23from sas.qtgui.Plotting.Plotter2D import Plotter2D
24from sas.qtgui.Plotting.MaskEditor import MaskEditor
25
26from sas.qtgui.MainWindow.DataManager import DataManager
27from sas.qtgui.MainWindow.DroppableDataLoadWidget import DroppableDataLoadWidget
28
29import sas.qtgui.Perspectives as Perspectives
30
31DEFAULT_PERSPECTIVE = "Fitting"
32
33logger = logging.getLogger(__name__)
34
35class DataExplorerWindow(DroppableDataLoadWidget):
36    # The controller which is responsible for managing signal slots connections
37    # for the gui and providing an interface to the data model.
38
39    def __init__(self, parent=None, guimanager=None, manager=None):
40        super(DataExplorerWindow, self).__init__(parent, guimanager)
41
42        # Main model for keeping loaded data
43        self.model = QtGui.QStandardItemModel(self)
44        # Secondary model for keeping frozen data sets
45        self.theory_model = QtGui.QStandardItemModel(self)
46
47        # GuiManager is the actual parent, but we needed to also pass the QMainWindow
48        # in order to set the widget parentage properly.
49        self.parent = guimanager
50        self.loader = Loader()
51        self.manager = manager if manager is not None else DataManager()
52        self.txt_widget = QtWidgets.QTextEdit(None)
53
54        # Be careful with twisted threads.
55        self.mutex = QtCore.QMutex()
56
57        # Plot widgets {name:widget}, required to keep track of plots shown as MDI subwindows
58        self.plot_widgets = {}
59
60        # Active plots {id:Plotter1D/2D}, required to keep track of currently displayed plots
61        self.active_plots = {}
62
63        # Connect the buttons
64        self.cmdLoad.clicked.connect(self.loadFile)
65        self.cmdDeleteData.clicked.connect(self.deleteFile)
66        self.cmdDeleteTheory.clicked.connect(self.deleteTheory)
67        self.cmdFreeze.clicked.connect(self.freezeTheory)
68        self.cmdSendTo.clicked.connect(self.sendData)
69        self.cmdNew.clicked.connect(self.newPlot)
70        self.cmdNew_2.clicked.connect(self.newPlot)
71        self.cmdAppend.clicked.connect(self.appendPlot)
72        self.cmdAppend_2.clicked.connect(self.appendPlot)
73        self.cmdHelp.clicked.connect(self.displayHelp)
74        self.cmdHelp_2.clicked.connect(self.displayHelp)
75
76        # Fill in the perspectives combo
77        self.initPerspectives()
78
79        # Custom context menu
80        self.treeView.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
81        self.treeView.customContextMenuRequested.connect(self.onCustomContextMenu)
82        self.contextMenu()
83
84        # Same menus for the theory view
85        self.freezeView.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
86        self.freezeView.customContextMenuRequested.connect(self.onCustomContextMenu)
87
88        # Connect the comboboxes
89        self.cbSelect.currentIndexChanged.connect(self.selectData)
90
91        #self.closeEvent.connect(self.closeEvent)
92        self.currentChanged.connect(self.onTabSwitch)
93        self.communicator = self.parent.communicator()
94        self.communicator.fileReadSignal.connect(self.loadFromURL)
95        self.communicator.activeGraphsSignal.connect(self.updateGraphCount)
96        self.communicator.activeGraphName.connect(self.updatePlotName)
97        self.communicator.plotUpdateSignal.connect(self.updatePlot)
98        self.communicator.maskEditorSignal.connect(self.showEditDataMask)
99        self.communicator.extMaskEditorSignal.connect(self.extShowEditDataMask)
100        self.communicator.changeDataExplorerTabSignal.connect(self.changeTabs)
101
102        self.cbgraph.editTextChanged.connect(self.enableGraphCombo)
103        self.cbgraph.currentIndexChanged.connect(self.enableGraphCombo)
104
105        # Proxy model for showing a subset of Data1D/Data2D content
106        self.data_proxy = QtCore.QSortFilterProxyModel(self)
107        self.data_proxy.setSourceModel(self.model)
108
109        # Don't show "empty" rows with data objects
110        self.data_proxy.setFilterRegExp(r"[^()]")
111
112        # The Data viewer is QTreeView showing the proxy model
113        self.treeView.setModel(self.data_proxy)
114
115        # Proxy model for showing a subset of Theory content
116        self.theory_proxy = QtCore.QSortFilterProxyModel(self)
117        self.theory_proxy.setSourceModel(self.theory_model)
118
119        # Don't show "empty" rows with data objects
120        self.theory_proxy.setFilterRegExp(r"[^()]")
121
122        # Theory model view
123        self.freezeView.setModel(self.theory_proxy)
124
125        self.enableGraphCombo(None)
126
127        # Current view on model
128        self.current_view = self.treeView
129
130    def closeEvent(self, event):
131        """
132        Overwrite the close event - no close!
133        """
134        event.ignore()
135
136    def onTabSwitch(self, index):
137        """ Callback for tab switching signal """
138        if index == 0:
139            self.current_view = self.treeView
140        else:
141            self.current_view = self.freezeView
142
143    def changeTabs(self, tab=0):
144        """
145        Switch tabs of the data explorer
146        0: data tab
147        1: theory tab
148        """
149        assert(tab in [0,1])
150        self.setCurrentIndex(tab)
151
152    def displayHelp(self):
153        """
154        Show the "Loading data" section of help
155        """
156        tree_location = "/user/qtgui/MainWindow/data_explorer_help.html"
157        self.parent.showHelp(tree_location)
158
159    def enableGraphCombo(self, combo_text):
160        """
161        Enables/disables "Assign Plot" elements
162        """
163        self.cbgraph.setEnabled(len(PlotHelper.currentPlots()) > 0)
164        self.cmdAppend.setEnabled(len(PlotHelper.currentPlots()) > 0)
165
166    def initPerspectives(self):
167        """
168        Populate the Perspective combobox and define callbacks
169        """
170        available_perspectives = sorted([p for p in list(Perspectives.PERSPECTIVES.keys())])
171        if available_perspectives:
172            self.cbFitting.clear()
173            self.cbFitting.addItems(available_perspectives)
174        self.cbFitting.currentIndexChanged.connect(self.updatePerspectiveCombo)
175        # Set the index so we see the default (Fitting)
176        self.cbFitting.setCurrentIndex(self.cbFitting.findText(DEFAULT_PERSPECTIVE))
177
178    def _perspective(self):
179        """
180        Returns the current perspective
181        """
182        return self.parent.perspective()
183
184    def loadFromURL(self, url):
185        """
186        Threaded file load
187        """
188        load_thread = threads.deferToThread(self.readData, url)
189        load_thread.addCallback(self.loadComplete)
190        load_thread.addErrback(self.loadFailed)
191
192    def loadFile(self, event=None):
193        """
194        Called when the "Load" button pressed.
195        Opens the Qt "Open File..." dialog
196        """
197        path_str = self.chooseFiles()
198        if not path_str:
199            return
200        self.loadFromURL(path_str)
201
202    def loadFolder(self, event=None):
203        """
204        Called when the "File/Load Folder" menu item chosen.
205        Opens the Qt "Open Folder..." dialog
206        """
207        folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Choose a directory", "",
208              QtWidgets.QFileDialog.ShowDirsOnly | QtWidgets.QFileDialog.DontUseNativeDialog)
209        if folder is None:
210            return
211
212        folder = str(folder)
213
214        if not os.path.isdir(folder):
215            return
216
217        # get content of dir into a list
218        path_str = [os.path.join(os.path.abspath(folder), filename)
219                    for filename in os.listdir(folder)]
220
221        self.loadFromURL(path_str)
222
223    def loadProject(self):
224        """
225        Called when the "Open Project" menu item chosen.
226        """
227        kwargs = {
228            'parent'    : self,
229            'caption'   : 'Open Project',
230            'filter'    : 'Project (*.json);;All files (*.*)',
231            'options'   : QtWidgets.QFileDialog.DontUseNativeDialog
232        }
233        filename = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0]
234        if filename:
235            load_thread = threads.deferToThread(self.readProject, filename)
236            load_thread.addCallback(self.readProjectComplete)
237            load_thread.addErrback(self.readProjectFailed)
238
239    def loadFailed(self, reason):
240        """
241        """
242        print("file load FAILED: ", reason)
243        pass
244
245    def readProjectFailed(self, reason):
246        """
247        """
248        print("readProjectFailed FAILED: ", reason)
249        pass
250
251    def readProject(self, filename):
252        self.communicator.statusBarUpdateSignal.emit("Loading Project... %s" % os.path.basename(filename))
253        try:
254            manager = DataManager()
255            with open(filename, 'r') as infile:
256                manager.load_from_readable(infile)
257
258            self.communicator.statusBarUpdateSignal.emit("Loaded Project: %s" % os.path.basename(filename))
259            return manager
260
261        except:
262            self.communicator.statusBarUpdateSignal.emit("Failed: %s" % os.path.basename(filename))
263            raise
264
265    def readProjectComplete(self, manager):
266        self.model.clear()
267
268        self.manager.assign(manager)
269        self.model.beginResetModel()
270        for id, item in self.manager.get_all_data().items():
271            self.updateModel(item.data, item.path)
272
273        self.model.endResetModel()
274
275    def saveProject(self):
276        """
277        Called when the "Save Project" menu item chosen.
278        """
279        kwargs = {
280            'parent'    : self,
281            'caption'   : 'Save Project',
282            'filter'    : 'Project (*.json)',
283            'options'   : QtWidgets.QFileDialog.DontUseNativeDialog
284        }
285        name_tuple = QtWidgets.QFileDialog.getSaveFileName(**kwargs)
286        filename = name_tuple[0]
287        if filename:
288            _, extension = os.path.splitext(filename)
289            if not extension:
290                filename = '.'.join((filename, 'json'))
291            self.communicator.statusBarUpdateSignal.emit("Saving Project... %s\n" % os.path.basename(filename))
292            with open(filename, 'w') as outfile:
293                self.manager.save_to_writable(outfile)
294
295    def deleteFile(self, event):
296        """
297        Delete selected rows from the model
298        """
299        # Assure this is indeed wanted
300        delete_msg = "This operation will delete the checked data sets and all the dependents." +\
301                     "\nDo you want to continue?"
302        reply = QtWidgets.QMessageBox.question(self,
303                                           'Warning',
304                                           delete_msg,
305                                           QtWidgets.QMessageBox.Yes,
306                                           QtWidgets.QMessageBox.No)
307
308        if reply == QtWidgets.QMessageBox.No:
309            return
310
311        # Figure out which rows are checked
312        ind = -1
313        # Use 'while' so the row count is forced at every iteration
314        deleted_items = []
315        deleted_names = []
316        while ind < self.model.rowCount():
317            ind += 1
318            item = self.model.item(ind)
319
320            if item and item.isCheckable() and item.checkState() == QtCore.Qt.Checked:
321                # Delete these rows from the model
322                deleted_names.append(str(self.model.item(ind).text()))
323                deleted_items.append(item)
324
325                self.model.removeRow(ind)
326                # Decrement index since we just deleted it
327                ind -= 1
328
329        # Let others know we deleted data
330        self.communicator.dataDeletedSignal.emit(deleted_items)
331
332        # update stored_data
333        self.manager.update_stored_data(deleted_names)
334
335    def deleteTheory(self, event):
336        """
337        Delete selected rows from the theory model
338        """
339        # Assure this is indeed wanted
340        delete_msg = "This operation will delete the checked data sets and all the dependents." +\
341                     "\nDo you want to continue?"
342        reply = QtWidgets.QMessageBox.question(self,
343                                           'Warning',
344                                           delete_msg,
345                                           QtWidgets.QMessageBox.Yes,
346                                           QtWidgets.QMessageBox.No)
347
348        if reply == QtWidgets.QMessageBox.No:
349            return
350
351        # Figure out which rows are checked
352        ind = -1
353        # Use 'while' so the row count is forced at every iteration
354        while ind < self.theory_model.rowCount():
355            ind += 1
356            item = self.theory_model.item(ind)
357            if item and item.isCheckable() and item.checkState() == QtCore.Qt.Checked:
358                # Delete these rows from the model
359                self.theory_model.removeRow(ind)
360                # Decrement index since we just deleted it
361                ind -= 1
362
363        # pass temporarily kept as a breakpoint anchor
364        pass
365
366    def sendData(self, event):
367        """
368        Send selected item data to the current perspective and set the relevant notifiers
369        """
370        # Set the signal handlers
371        self.communicator.updateModelFromPerspectiveSignal.connect(self.updateModelFromPerspective)
372
373        def isItemReady(index):
374            item = self.model.item(index)
375            return item.isCheckable() and item.checkState() == QtCore.Qt.Checked
376
377        # Figure out which rows are checked
378        selected_items = [self.model.item(index)
379                          for index in range(self.model.rowCount())
380                          if isItemReady(index)]
381
382        if len(selected_items) < 1:
383            return
384
385        # Which perspective has been selected?
386        if len(selected_items) > 1 and not self._perspective().allowBatch():
387            msg = self._perspective().title() + " does not allow multiple data."
388            msgbox = QtWidgets.QMessageBox()
389            msgbox.setIcon(QtWidgets.QMessageBox.Critical)
390            msgbox.setText(msg)
391            msgbox.setStandardButtons(QtWidgets.QMessageBox.Ok)
392            retval = msgbox.exec_()
393            return
394
395        # Notify the GuiManager about the send request
396        self._perspective().setData(data_item=selected_items, is_batch=self.chkBatch.isChecked())
397
398    def freezeCheckedData(self):
399        """
400        Convert checked results (fitted model, residuals) into separate dataset.
401        """
402        outer_index = -1
403        theories_copied = 0
404        orig_model_size = self.model.rowCount()
405        while outer_index < orig_model_size:
406            outer_index += 1
407            outer_item = self.model.item(outer_index)
408            if not outer_item:
409                continue
410            if not outer_item.isCheckable():
411                continue
412            # Look for checked inner items
413            inner_index = -1
414            while inner_index < outer_item.rowCount():
415               inner_item = outer_item.child(inner_index)
416               inner_index += 1
417               if not inner_item:
418                   continue
419               if not inner_item.isCheckable():
420                   continue
421               if inner_item.checkState() != QtCore.Qt.Checked:
422                   continue
423               self.model.beginResetModel()
424               theories_copied += 1
425               new_item = self.cloneTheory(inner_item)
426               self.model.appendRow(new_item)
427               self.model.endResetModel()
428
429        freeze_msg = ""
430        if theories_copied == 0:
431            return
432        elif theories_copied == 1:
433            freeze_msg = "1 theory copied to a separate data set"
434        elif theories_copied > 1:
435            freeze_msg = "%i theories copied to separate data sets" % theories_copied
436        else:
437            freeze_msg = "Unexpected number of theories copied: %i" % theories_copied
438            raise AttributeError(freeze_msg)
439        self.communicator.statusBarUpdateSignal.emit(freeze_msg)
440
441    def freezeTheory(self, event):
442        """
443        Freeze selected theory rows.
444
445        "Freezing" means taking the plottable data from the Theory item
446        and copying it to a separate top-level item in Data.
447        """
448        # Figure out which rows are checked
449        # Use 'while' so the row count is forced at every iteration
450        outer_index = -1
451        theories_copied = 0
452        while outer_index < self.theory_model.rowCount():
453            outer_index += 1
454            outer_item = self.theory_model.item(outer_index)
455            if not outer_item:
456                continue
457            if outer_item.isCheckable() and \
458                   outer_item.checkState() == QtCore.Qt.Checked:
459                self.model.beginResetModel()
460                theories_copied += 1
461                new_item = self.cloneTheory(outer_item)
462                self.model.appendRow(new_item)
463                self.model.endResetModel()
464
465        freeze_msg = ""
466        if theories_copied == 0:
467            return
468        elif theories_copied == 1:
469            freeze_msg = "1 theory copied from the Theory tab as a data set"
470        elif theories_copied > 1:
471            freeze_msg = "%i theories copied from the Theory tab as data sets" % theories_copied
472        else:
473            freeze_msg = "Unexpected number of theories copied: %i" % theories_copied
474            raise AttributeError(freeze_msg)
475        self.communicator.statusBarUpdateSignal.emit(freeze_msg)
476        # Actively switch tabs
477        self.setCurrentIndex(1)
478
479    def cloneTheory(self, item_from):
480        """
481        Manually clone theory items into a new HashableItem
482        """
483        new_item = GuiUtils.HashableStandardItem()
484        new_item.setCheckable(True)
485        new_item.setCheckState(QtCore.Qt.Checked)
486        info_item = QtGui.QStandardItem("Info")
487        data_item = QtGui.QStandardItem()
488        data_item.setData(item_from.child(0).data())
489        new_item.setText(item_from.text())
490        new_item.setChild(0, data_item)
491        new_item.setChild(1, info_item)
492        # Append a "unique" descriptor to the name
493        time_bit = str(time.time())[7:-1].replace('.', '')
494        new_name = new_item.text() + '_@' + time_bit
495        new_item.setText(new_name)
496        # Change the underlying data so it is no longer a theory
497        try:
498            new_item.child(0).data().is_data = True
499        except AttributeError:
500            #no data here, pass
501            pass
502        return new_item
503
504    def recursivelyCloneItem(self, item):
505        """
506        Clone QStandardItem() object
507        """
508        new_item = item.clone()
509        # clone doesn't do deepcopy :(
510        for child_index in range(item.rowCount()):
511            child_item = self.recursivelyCloneItem(item.child(child_index))
512            new_item.setChild(child_index, child_item)
513        return new_item
514
515    def updatePlotName(self, name_tuple):
516        """
517        Modify the name of the current plot
518        """
519        old_name, current_name = name_tuple
520        ind = self.cbgraph.findText(old_name)
521        self.cbgraph.setCurrentIndex(ind)
522        self.cbgraph.setItemText(ind, current_name)
523
524    def updateGraphCount(self, graph_list):
525        """
526        Modify the graph name combo and potentially remove
527        deleted graphs
528        """
529        self.updateGraphCombo(graph_list)
530
531        if not self.active_plots:
532            return
533        new_plots = [PlotHelper.plotById(plot) for plot in graph_list]
534        active_plots_copy = list(self.active_plots.keys())
535        for plot in active_plots_copy:
536            if self.active_plots[plot] in new_plots:
537                continue
538            self.active_plots.pop(plot)
539
540    def updateGraphCombo(self, graph_list):
541        """
542        Modify Graph combo box on graph add/delete
543        """
544        orig_text = self.cbgraph.currentText()
545        self.cbgraph.clear()
546        self.cbgraph.insertItems(0, graph_list)
547        ind = self.cbgraph.findText(orig_text)
548        if ind > 0:
549            self.cbgraph.setCurrentIndex(ind)
550
551    def updatePerspectiveCombo(self, index):
552        """
553        Notify the gui manager about the new perspective chosen.
554        """
555        self.communicator.perspectiveChangedSignal.emit(self.cbFitting.itemText(index))
556        self.chkBatch.setEnabled(self.parent.perspective().allowBatch())
557
558    def itemFromFilename(self, filename):
559        """
560        Retrieves model item corresponding to the given filename
561        """
562        item = GuiUtils.itemFromFilename(filename, self.model)
563        return item
564
565    def displayFile(self, filename=None, is_data=True, id=None):
566        """
567        Forces display of charts for the given filename
568        """
569        model = self.model if is_data else self.theory_model
570        # Now query the model item for available plots
571        plots = GuiUtils.plotsFromFilename(filename, model)
572        # Each fitpage contains the name based on fit widget number
573        fitpage_name = "" if id is None else "M"+str(id)
574        new_plots = []
575        for item, plot in plots.items():
576            if self.updatePlot(plot) or filename not in plot.name:
577                continue
578            # Don't plot intermediate results, e.g. P(Q), S(Q)
579            match = GuiUtils.theory_plot_ID_pattern.match(plot.id)
580            # 2nd match group contains the identifier for the intermediate result, if present (e.g. "[P(Q)]")
581            if match and match.groups()[1] != None:
582                continue
583            # Don't include plots from different fitpages, but always include the original data
584            if fitpage_name in plot.name or filename == plot.name:
585                # Residuals get their own plot
586                if plot.plot_role == Data1D.ROLE_RESIDUAL:
587                    plot.yscale='linear'
588                    self.plotData([(item, plot)])
589                else:
590                    new_plots.append((item, plot))
591
592        if new_plots:
593            self.plotData(new_plots)
594
595    def displayData(self, data_list, id=None):
596        """
597        Forces display of charts for the given data set
598        """
599        plot_to_show = data_list[0]
600        # passed plot is used ONLY to figure out its title,
601        # so all the charts related by it can be pulled from
602        # the data explorer indices.
603        filename = plot_to_show.filename
604        self.displayFile(filename=filename, is_data=plot_to_show.is_data, id=id)
605
606    def addDataPlot2D(self, plot_set, item):
607        """
608        Create a new 2D plot and add it to the workspace
609        """
610        plot2D = Plotter2D(self)
611        plot2D.item = item
612        plot2D.plot(plot_set)
613        self.addPlot(plot2D)
614        self.active_plots[plot2D.data.name] = plot2D
615        #============================================
616        # Experimental hook for silx charts
617        #============================================
618        ## Attach silx
619        #from silx.gui import qt
620        #from silx.gui.plot import StackView
621        #sv = StackView()
622        #sv.setColormap("jet", autoscale=True)
623        #sv.setStack(plot_set.data.reshape(1,100,100))
624        ##sv.setLabels(["x: -10 to 10 (200 samples)",
625        ##              "y: -10 to 5 (150 samples)"])
626        #sv.show()
627        #============================================
628
629    def plotData(self, plots, transform=True):
630        """
631        Takes 1D/2D data and generates a single plot (1D) or multiple plots (2D)
632        """
633        # Call show on requested plots
634        # All same-type charts in one plot
635        for item, plot_set in plots:
636            if isinstance(plot_set, Data1D):
637                if not 'new_plot' in locals():
638                    new_plot = Plotter(self)
639                    new_plot.item = item
640                new_plot.plot(plot_set, transform=transform)
641                # active_plots may contain multiple charts
642                self.active_plots[plot_set.name] = new_plot
643            elif isinstance(plot_set, Data2D):
644                self.addDataPlot2D(plot_set, item)
645            else:
646                msg = "Incorrect data type passed to Plotting"
647                raise AttributeError(msg)
648
649        if 'new_plot' in locals() and \
650            hasattr(new_plot, 'data') and \
651            isinstance(new_plot.data, Data1D):
652                self.addPlot(new_plot)
653
654    def newPlot(self):
655        """
656        Select checked data and plot it
657        """
658        # Check which tab is currently active
659        if self.current_view == self.treeView:
660            plots = GuiUtils.plotsFromCheckedItems(self.model)
661        else:
662            plots = GuiUtils.plotsFromCheckedItems(self.theory_model)
663
664        self.plotData(plots)
665
666    def addPlot(self, new_plot):
667        """
668        Helper method for plot bookkeeping
669        """
670        # Update the global plot counter
671        title = str(PlotHelper.idOfPlot(new_plot))
672        new_plot.setWindowTitle(title)
673
674        # Set the object name to satisfy the Squish object picker
675        new_plot.setObjectName(title)
676
677        # Add the plot to the workspace
678        plot_widget = self.parent.workspace().addSubWindow(new_plot)
679
680        # Show the plot
681        new_plot.show()
682        new_plot.canvas.draw()
683
684        # Update the plot widgets dict
685        self.plot_widgets[title]=plot_widget
686
687        # Update the active chart list
688        self.active_plots[new_plot.data.name] = new_plot
689
690    def appendPlot(self):
691        """
692        Add data set(s) to the existing matplotlib chart
693        """
694        # new plot data; check which tab is currently active
695        if self.current_view == self.treeView:
696            new_plots = GuiUtils.plotsFromCheckedItems(self.model)
697        else:
698            new_plots = GuiUtils.plotsFromCheckedItems(self.theory_model)
699
700        # old plot data
701        plot_id = str(self.cbgraph.currentText())
702
703        assert plot_id in PlotHelper.currentPlots(), "No such plot: %s"%(plot_id)
704
705        old_plot = PlotHelper.plotById(plot_id)
706
707        # Add new data to the old plot, if data type is the same.
708        for _, plot_set in new_plots:
709            if type(plot_set) is type(old_plot._data):
710                old_plot.data = plot_set
711                old_plot.plot()
712                # need this for lookup - otherwise this plot will never update
713                self.active_plots[plot_set.name] = old_plot
714
715    def updatePlot(self, data):
716        """
717        Modify existing plot for immediate response and returns True.
718        Returns false, if the plot does not exist already.
719        """
720        try: # there might be a list or a single value being passed
721            data = data[0]
722        except TypeError:
723            pass
724        assert type(data).__name__ in ['Data1D', 'Data2D']
725
726        ids_keys = list(self.active_plots.keys())
727        ids_vals = [val.data.name for val in self.active_plots.values()]
728
729        data_id = data.name
730        if data_id in ids_keys:
731            # We have data, let's replace data that needs replacing
732            if data.plot_role != Data1D.ROLE_DATA:
733                self.active_plots[data_id].replacePlot(data_id, data)
734            return True
735        elif data_id in ids_vals:
736            if data.plot_role != Data1D.ROLE_DATA:
737                list(self.active_plots.values())[ids_vals.index(data_id)].replacePlot(data_id, data)
738            return True
739        return False
740
741    def chooseFiles(self):
742        """
743        Shows the Open file dialog and returns the chosen path(s)
744        """
745        # List of known extensions
746        wlist = self.getWlist()
747
748        # Location is automatically saved - no need to keep track of the last dir
749        # But only with Qt built-in dialog (non-platform native)
750        paths = QtWidgets.QFileDialog.getOpenFileNames(self, "Choose a file", "",
751                wlist, None, QtWidgets.QFileDialog.DontUseNativeDialog)[0]
752        if not paths:
753            return
754
755        if not isinstance(paths, list):
756            paths = [paths]
757
758        return paths
759
760    def readData(self, path):
761        """
762        verbatim copy-paste from
763           sasgui.guiframe.local_perspectives.data_loader.data_loader.py
764        slightly modified for clarity
765        """
766        message = ""
767        log_msg = ''
768        output = {}
769        any_error = False
770        data_error = False
771        error_message = ""
772        number_of_files = len(path)
773        self.communicator.progressBarUpdateSignal.emit(0.0)
774
775        for index, p_file in enumerate(path):
776            basename = os.path.basename(p_file)
777            _, extension = os.path.splitext(basename)
778            if extension.lower() in GuiUtils.EXTENSIONS:
779                any_error = True
780                log_msg = "Data Loader cannot "
781                log_msg += "load: %s\n" % str(p_file)
782                log_msg += """Please try to open that file from "open project" """
783                log_msg += """or "open analysis" menu\n"""
784                error_message = log_msg + "\n"
785                logging.info(log_msg)
786                continue
787
788            try:
789                message = "Loading Data... " + str(basename) + "\n"
790
791                # change this to signal notification in GuiManager
792                self.communicator.statusBarUpdateSignal.emit(message)
793
794                output_objects = self.loader.load(p_file)
795
796                # Some loaders return a list and some just a single Data1D object.
797                # Standardize.
798                if not isinstance(output_objects, list):
799                    output_objects = [output_objects]
800
801                for item in output_objects:
802                    # cast sascalc.dataloader.data_info.Data1D into
803                    # sasgui.guiframe.dataFitting.Data1D
804                    # TODO : Fix it
805                    new_data = self.manager.create_gui_data(item, p_file)
806                    output[new_data.id] = new_data
807
808                    # Model update should be protected
809                    self.mutex.lock()
810                    self.updateModel(new_data, p_file)
811                    #self.model.reset()
812                    QtWidgets.QApplication.processEvents()
813                    self.mutex.unlock()
814
815                    if hasattr(item, 'errors'):
816                        for error_data in item.errors:
817                            data_error = True
818                            message += "\tError: {0}\n".format(error_data)
819                    else:
820
821                        logging.error("Loader returned an invalid object:\n %s" % str(item))
822                        data_error = True
823
824            except Exception as ex:
825                logging.error(sys.exc_info()[1])
826
827                any_error = True
828            if any_error or error_message != "":
829                if error_message == "":
830                    error = "Error: " + str(sys.exc_info()[1]) + "\n"
831                    error += "while loading Data: \n%s\n" % str(basename)
832                    error_message += "The data file you selected could not be loaded.\n"
833                    error_message += "Make sure the content of your file"
834                    error_message += " is properly formatted.\n\n"
835                    error_message += "When contacting the SasView team, mention the"
836                    error_message += " following:\n%s" % str(error)
837                elif data_error:
838                    base_message = "Errors occurred while loading "
839                    base_message += "{0}\n".format(basename)
840                    base_message += "The data file loaded but with errors.\n"
841                    error_message = base_message + error_message
842                else:
843                    error_message += "%s\n" % str(p_file)
844
845            current_percentage = int(100.0* index/number_of_files)
846            self.communicator.progressBarUpdateSignal.emit(current_percentage)
847
848        if any_error or error_message:
849            logging.error(error_message)
850            status_bar_message = "Errors occurred while loading %s" % format(basename)
851            self.communicator.statusBarUpdateSignal.emit(status_bar_message)
852
853        else:
854            message = "Loading Data Complete! "
855        message += log_msg
856        # Notify the progress bar that the updates are over.
857        self.communicator.progressBarUpdateSignal.emit(-1)
858        self.communicator.statusBarUpdateSignal.emit(message)
859
860        return output, message
861
862    def getWlist(self):
863        """
864        Wildcards of files we know the format of.
865        """
866        # Display the Qt Load File module
867        cards = self.loader.get_wildcards()
868
869        # get rid of the wx remnant in wildcards
870        # TODO: modify sasview loader get_wildcards method, after merge,
871        # so this kludge can be avoided
872        new_cards = []
873        for item in cards:
874            new_cards.append(item[:item.find("|")])
875        wlist = ';;'.join(new_cards)
876
877        return wlist
878
879    def setItemsCheckability(self, model, dimension=None, checked=False):
880        """
881        For a given model, check or uncheck all items of given dimension
882        """
883        mode = QtCore.Qt.Checked if checked else QtCore.Qt.Unchecked
884
885        assert isinstance(checked, bool)
886
887        types = (None, Data1D, Data2D)
888        assert dimension in types
889
890        for index in range(model.rowCount()):
891            item = model.item(index)
892            if dimension is not None and not isinstance(GuiUtils.dataFromItem(item), dimension):
893                continue
894            if item.isCheckable() and item.checkState() != mode:
895                item.setCheckState(mode)
896            # look for all children
897            for inner_index in range(item.rowCount()):
898                child = item.child(inner_index)
899                if child.isCheckable() and child.checkState() != mode:
900                    child.setCheckState(mode)
901
902    def selectData(self, index):
903        """
904        Callback method for modifying the TreeView on Selection Options change
905        """
906        if not isinstance(index, int):
907            msg = "Incorrect type passed to DataExplorer.selectData()"
908            raise AttributeError(msg)
909
910        # Respond appropriately
911        if index == 0:
912            self.setItemsCheckability(self.model, checked=True)
913
914        elif index == 1:
915            # De-select All
916            self.setItemsCheckability(self.model, checked=False)
917
918        elif index == 2:
919            # Select All 1-D
920            self.setItemsCheckability(self.model, dimension=Data1D, checked=True)
921
922        elif index == 3:
923            # Unselect All 1-D
924            self.setItemsCheckability(self.model, dimension=Data1D, checked=False)
925
926        elif index == 4:
927            # Select All 2-D
928            self.setItemsCheckability(self.model, dimension=Data2D, checked=True)
929
930        elif index == 5:
931            # Unselect All 2-D
932            self.setItemsCheckability(self.model, dimension=Data2D, checked=False)
933
934        else:
935            msg = "Incorrect value in the Selection Option"
936            # Change this to a proper logging action
937            raise Exception(msg)
938
939    def contextMenu(self):
940        """
941        Define actions and layout of the right click context menu
942        """
943        # Create a custom menu based on actions defined in the UI file
944        self.context_menu = QtWidgets.QMenu(self)
945        self.context_menu.addAction(self.actionDataInfo)
946        self.context_menu.addAction(self.actionSaveAs)
947        self.context_menu.addAction(self.actionQuickPlot)
948        self.context_menu.addSeparator()
949        self.context_menu.addAction(self.actionQuick3DPlot)
950        self.context_menu.addAction(self.actionEditMask)
951        #self.context_menu.addSeparator()
952        #self.context_menu.addAction(self.actionFreezeResults)
953        self.context_menu.addSeparator()
954        self.context_menu.addAction(self.actionDelete)
955
956
957        # Define the callbacks
958        self.actionDataInfo.triggered.connect(self.showDataInfo)
959        self.actionSaveAs.triggered.connect(self.saveDataAs)
960        self.actionQuickPlot.triggered.connect(self.quickDataPlot)
961        self.actionQuick3DPlot.triggered.connect(self.quickData3DPlot)
962        self.actionEditMask.triggered.connect(self.showEditDataMask)
963        self.actionDelete.triggered.connect(self.deleteItem)
964        self.actionFreezeResults.triggered.connect(self.freezeSelectedItems)
965
966    def onCustomContextMenu(self, position):
967        """
968        Show the right-click context menu in the data treeview
969        """
970        index = self.current_view.indexAt(position)
971        proxy = self.current_view.model()
972        model = proxy.sourceModel()
973
974        if not index.isValid():
975            return
976        model_item = model.itemFromIndex(proxy.mapToSource(index))
977        # Find the mapped index
978        orig_index = model_item.isCheckable()
979        if not orig_index:
980            return
981        # Check the data to enable/disable actions
982        is_2D = isinstance(GuiUtils.dataFromItem(model_item), Data2D)
983        self.actionQuick3DPlot.setEnabled(is_2D)
984        self.actionEditMask.setEnabled(is_2D)
985
986        # Freezing
987        # check that the selection has inner items
988        freeze_enabled = False
989        if model_item.parent() is not None:
990            freeze_enabled = True
991        self.actionFreezeResults.setEnabled(freeze_enabled)
992
993        # Fire up the menu
994        self.context_menu.exec_(self.current_view.mapToGlobal(position))
995
996    def showDataInfo(self):
997        """
998        Show a simple read-only text edit with data information.
999        """
1000        index = self.current_view.selectedIndexes()[0]
1001        proxy = self.current_view.model()
1002        model = proxy.sourceModel()
1003        model_item = model.itemFromIndex(proxy.mapToSource(index))
1004
1005        data = GuiUtils.dataFromItem(model_item)
1006        if isinstance(data, Data1D):
1007            text_to_show = GuiUtils.retrieveData1d(data)
1008            # Hardcoded sizes to enable full width rendering with default font
1009            self.txt_widget.resize(420,600)
1010        else:
1011            text_to_show = GuiUtils.retrieveData2d(data)
1012            # Hardcoded sizes to enable full width rendering with default font
1013            self.txt_widget.resize(700,600)
1014
1015        self.txt_widget.setReadOnly(True)
1016        self.txt_widget.setWindowFlags(QtCore.Qt.Window)
1017        self.txt_widget.setWindowIcon(QtGui.QIcon(":/res/ball.ico"))
1018        self.txt_widget.setWindowTitle("Data Info: %s" % data.filename)
1019        self.txt_widget.clear()
1020        self.txt_widget.insertPlainText(text_to_show)
1021
1022        self.txt_widget.show()
1023        # Move the slider all the way up, if present
1024        vertical_scroll_bar = self.txt_widget.verticalScrollBar()
1025        vertical_scroll_bar.triggerAction(QtWidgets.QScrollBar.SliderToMinimum)
1026
1027    def saveDataAs(self):
1028        """
1029        Save the data points as either txt or xml
1030        """
1031        index = self.current_view.selectedIndexes()[0]
1032        proxy = self.current_view.model()
1033        model = proxy.sourceModel()
1034        model_item = model.itemFromIndex(proxy.mapToSource(index))
1035
1036        data = GuiUtils.dataFromItem(model_item)
1037        if isinstance(data, Data1D):
1038            GuiUtils.saveData1D(data)
1039        else:
1040            GuiUtils.saveData2D(data)
1041
1042    def quickDataPlot(self):
1043        """
1044        Frozen plot - display an image of the plot
1045        """
1046        index = self.current_view.selectedIndexes()[0]
1047        proxy = self.current_view.model()
1048        model = proxy.sourceModel()
1049        model_item = model.itemFromIndex(proxy.mapToSource(index))
1050
1051        data = GuiUtils.dataFromItem(model_item)
1052
1053        method_name = 'Plotter'
1054        if isinstance(data, Data2D):
1055            method_name='Plotter2D'
1056
1057        self.new_plot = globals()[method_name](self, quickplot=True)
1058        self.new_plot.data = data
1059        #new_plot.plot(marker='o')
1060        self.new_plot.plot()
1061
1062        # Update the global plot counter
1063        title = "Plot " + data.name
1064        self.new_plot.setWindowTitle(title)
1065
1066        # Show the plot
1067        self.new_plot.show()
1068
1069    def quickData3DPlot(self):
1070        """
1071        Slowish 3D plot
1072        """
1073        index = self.current_view.selectedIndexes()[0]
1074        proxy = self.current_view.model()
1075        model = proxy.sourceModel()
1076        model_item = model.itemFromIndex(proxy.mapToSource(index))
1077
1078        data = GuiUtils.dataFromItem(model_item)
1079
1080        self.new_plot = Plotter2D(self, quickplot=True, dimension=3)
1081        self.new_plot.data = data
1082        self.new_plot.plot()
1083
1084        # Update the global plot counter
1085        title = "Plot " + data.name
1086        self.new_plot.setWindowTitle(title)
1087
1088        # Show the plot
1089        self.new_plot.show()
1090
1091    def extShowEditDataMask(self):
1092        self.showEditDataMask()
1093
1094    def showEditDataMask(self, data=None):
1095        """
1096        Mask Editor for 2D plots
1097        """
1098        try:
1099            if data is None or not isinstance(data, Data2D):
1100                index = self.current_view.selectedIndexes()[0]
1101                proxy = self.current_view.model()
1102                model = proxy.sourceModel()
1103                model_item = model.itemFromIndex(proxy.mapToSource(index))
1104
1105                data = GuiUtils.dataFromItem(model_item)
1106
1107            if data is None or not isinstance(data, Data2D):
1108                msg = QtWidgets.QMessageBox()
1109                msg.setIcon(QtWidgets.QMessageBox.Information)
1110                msg.setText("Error: cannot apply mask. \
1111                                Please select a 2D dataset.")
1112                msg.setStandardButtons(QtWidgets.QMessageBox.Cancel)
1113                msg.exec_()
1114                return
1115        except:
1116            msg = QtWidgets.QMessageBox()
1117            msg.setIcon(QtWidgets.QMessageBox.Information)
1118            msg.setText("Error: No dataset selected. \
1119                            Please select a 2D dataset.")
1120            msg.setStandardButtons(QtWidgets.QMessageBox.Cancel)
1121            msg.exec_()
1122            return
1123
1124        mask_editor = MaskEditor(self, data)
1125        # Modal dialog here.
1126        mask_editor.exec_()
1127
1128    def freezeItem(self, item=None):
1129        """
1130        Freeze given item
1131        """
1132        if item is None:
1133            return
1134        self.model.beginResetModel()
1135        new_item = self.cloneTheory(item)
1136        self.model.appendRow(new_item)
1137        self.model.endResetModel()
1138
1139    def freezeSelectedItems(self):
1140        """
1141        Freeze selected items
1142        """
1143        indices = self.treeView.selectedIndexes()
1144
1145        proxy = self.treeView.model()
1146        model = proxy.sourceModel()
1147
1148        for index in indices:
1149            row_index = proxy.mapToSource(index)
1150            item_to_copy = model.itemFromIndex(row_index)
1151            if item_to_copy and item_to_copy.isCheckable():
1152                self.freezeItem(item_to_copy)
1153
1154    def deleteItem(self):
1155        """
1156        Delete the current item
1157        """
1158        # Assure this is indeed wanted
1159        delete_msg = "This operation will delete the selected data sets " +\
1160                     "and all the dependents." +\
1161                     "\nDo you want to continue?"
1162        reply = QtWidgets.QMessageBox.question(self,
1163                                           'Warning',
1164                                           delete_msg,
1165                                           QtWidgets.QMessageBox.Yes,
1166                                           QtWidgets.QMessageBox.No)
1167
1168        if reply == QtWidgets.QMessageBox.No:
1169            return
1170
1171        # Every time a row is removed, the indices change, so we'll just remove
1172        # rows and keep calling selectedIndexes until it returns an empty list.
1173        indices = self.current_view.selectedIndexes()
1174
1175        proxy = self.current_view.model()
1176        model = proxy.sourceModel()
1177
1178        deleted_items = []
1179        deleted_names = []
1180
1181        while len(indices) > 0:
1182            index = indices[0]
1183            row_index = proxy.mapToSource(index)
1184            item_to_delete = model.itemFromIndex(row_index)
1185            if item_to_delete and item_to_delete.isCheckable():
1186                row = row_index.row()
1187
1188                # store the deleted item details so we can pass them on later
1189                deleted_names.append(item_to_delete.text())
1190                deleted_items.append(item_to_delete)
1191
1192                # Delete corresponding open plots
1193                self.closePlotsForItem(item_to_delete)
1194
1195                if item_to_delete.parent():
1196                    # We have a child item - delete from it
1197                    item_to_delete.parent().removeRow(row)
1198                else:
1199                    # delete directly from model
1200                    model.removeRow(row)
1201            indices = self.current_view.selectedIndexes()
1202
1203        # Let others know we deleted data
1204        self.communicator.dataDeletedSignal.emit(deleted_items)
1205
1206        # update stored_data
1207        self.manager.update_stored_data(deleted_names)
1208
1209    def closePlotsForItem(self, item):
1210        """
1211        Given standard item, close all its currently displayed plots
1212        """
1213        # item - HashableStandardItems of active plots
1214
1215        # {} -> 'Graph1' : HashableStandardItem()
1216        current_plot_items = {}
1217        for plot_name in PlotHelper.currentPlots():
1218            current_plot_items[plot_name] = PlotHelper.plotById(plot_name).item
1219
1220        # item and its hashable children
1221        items_being_deleted = []
1222        if item.rowCount() > 0:
1223            items_being_deleted = [item.child(n) for n in range(item.rowCount())
1224                                   if isinstance(item.child(n), GuiUtils.HashableStandardItem)]
1225        items_being_deleted.append(item)
1226        # Add the parent in case a child is selected
1227        if isinstance(item.parent(), GuiUtils.HashableStandardItem):
1228            items_being_deleted.append(item.parent())
1229
1230        # Compare plot items and items to delete
1231        plots_to_close = set(current_plot_items.values()) & set(items_being_deleted)
1232
1233        for plot_item in plots_to_close:
1234            for plot_name in current_plot_items.keys():
1235                if plot_item == current_plot_items[plot_name]:
1236                    plotter = PlotHelper.plotById(plot_name)
1237                    # try to delete the plot
1238                    try:
1239                        plotter.close()
1240                        #self.parent.workspace().removeSubWindow(plotter)
1241                        self.plot_widgets[plot_name].close()
1242                        self.plot_widgets.pop(plot_name, None)
1243                    except AttributeError as ex:
1244                        logging.error("Closing of %s failed:\n %s" % (plot_name, str(ex)))
1245
1246        pass # debugger anchor
1247
1248    def onAnalysisUpdate(self, new_perspective=""):
1249        """
1250        Update the perspective combo index based on passed string
1251        """
1252        assert new_perspective in Perspectives.PERSPECTIVES.keys()
1253        self.cbFitting.blockSignals(True)
1254        self.cbFitting.setCurrentIndex(self.cbFitting.findText(new_perspective))
1255        self.cbFitting.blockSignals(False)
1256        pass
1257
1258    def loadComplete(self, output):
1259        """
1260        Post message to status bar and update the data manager
1261        """
1262        assert isinstance(output, tuple)
1263
1264        # Reset the model so the view gets updated.
1265        #self.model.reset()
1266        self.communicator.progressBarUpdateSignal.emit(-1)
1267
1268        output_data = output[0]
1269        message = output[1]
1270        # Notify the manager of the new data available
1271        self.communicator.statusBarUpdateSignal.emit(message)
1272        self.communicator.fileDataReceivedSignal.emit(output_data)
1273        self.manager.add_data(data_list=output_data)
1274
1275    def loadFailed(self, reason):
1276        print("File Load Failed with:\n", reason)
1277        pass
1278
1279    def updateModel(self, data, p_file):
1280        """
1281        Add data and Info fields to the model item
1282        """
1283        # Structure of the model
1284        # checkbox + basename
1285        #     |-------> Data.D object
1286        #     |-------> Info
1287        #                 |----> Title:
1288        #                 |----> Run:
1289        #                 |----> Type:
1290        #                 |----> Path:
1291        #                 |----> Process
1292        #                          |-----> process[0].name
1293        #     |-------> THEORIES
1294
1295        # Top-level item: checkbox with label
1296        checkbox_item = GuiUtils.HashableStandardItem()
1297        checkbox_item.setCheckable(True)
1298        checkbox_item.setCheckState(QtCore.Qt.Checked)
1299        checkbox_item.setText(os.path.basename(p_file))
1300
1301        # Add the actual Data1D/Data2D object
1302        object_item = GuiUtils.HashableStandardItem()
1303        object_item.setData(data)
1304
1305        checkbox_item.setChild(0, object_item)
1306
1307        # Add rows for display in the view
1308        info_item = GuiUtils.infoFromData(data)
1309
1310        # Set info_item as the first child
1311        checkbox_item.setChild(1, info_item)
1312
1313        # Caption for the theories
1314        checkbox_item.setChild(2, QtGui.QStandardItem("FIT RESULTS"))
1315
1316        # New row in the model
1317        self.model.beginResetModel()
1318        self.model.appendRow(checkbox_item)
1319        self.model.endResetModel()
1320
1321    def updateModelFromPerspective(self, model_item):
1322        """
1323        Receive an update model item from a perspective
1324        Make sure it is valid and if so, replace it in the model
1325        """
1326        # Assert the correct type
1327        if not isinstance(model_item, QtGui.QStandardItem):
1328            msg = "Wrong data type returned from calculations."
1329            raise AttributeError(msg)
1330
1331        # TODO: Assert other properties
1332
1333        # Reset the view
1334        ##self.model.reset()
1335        # Pass acting as a debugger anchor
1336        pass
1337
1338    def updateTheoryFromPerspective(self, model_item):
1339        """
1340        Receive an update theory item from a perspective
1341        Make sure it is valid and if so, replace/add in the model
1342        """
1343        # Assert the correct type
1344        if not isinstance(model_item, QtGui.QStandardItem):
1345            msg = "Wrong data type returned from calculations."
1346            raise AttributeError(msg)
1347
1348        # Check if there are any other items for this tab
1349        # If so, delete them
1350        current_tab_name = model_item.text()
1351        for current_index in range(self.theory_model.rowCount()):
1352            #if current_tab_name in self.theory_model.item(current_index).text():
1353            if current_tab_name == self.theory_model.item(current_index).text():
1354                self.theory_model.removeRow(current_index)
1355                break
1356
1357        # send in the new item
1358        self.theory_model.appendRow(model_item)
1359
1360    def deleteIntermediateTheoryPlotsByModelID(self, model_id):
1361        """Given a model's ID, deletes all items in the theory item model which reference the same ID. Useful in the
1362        case of intermediate results disappearing when changing calculations (in which case you don't want them to be
1363        retained in the list)."""
1364        items_to_delete = []
1365        for r in range(self.theory_model.rowCount()):
1366            item = self.theory_model.item(r, 0)
1367            data = item.child(0).data()
1368            if not hasattr(data, "id"):
1369                return
1370            match = GuiUtils.theory_plot_ID_pattern.match(data.id)
1371            if match:
1372                item_model_id = match.groups()[-1]
1373                if item_model_id == model_id:
1374                    # Only delete those identified as an intermediate plot
1375                    if match.groups()[2] not in (None, ""):
1376                        items_to_delete.append(item)
1377
1378        for item in items_to_delete:
1379            self.theory_model.removeRow(item.row())
Note: See TracBrowser for help on using the repository browser.