source: sasview/src/sas/qtgui/MainWindow/DataExplorer.py @ 3ae70f9

ESS_GUIESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since 3ae70f9 was 3ae70f9, checked in by ibressler, 6 years ago

plot the polydispersity SASVIEW-1035

  • Property mode set to 100644
File size: 50.8 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):
577                # Don't create plots which are already displayed
578                continue
579            # Don't plot intermediate results, e.g. P(Q), S(Q)
580            match = GuiUtils.theory_plot_ID_pattern.match(plot.id)
581            # 2nd match group contains the identifier for the intermediate
582            # result, if present (e.g. "[P(Q)]")
583            if match and match.groups()[1] != None:
584                continue
585            # Don't include plots from different fitpages,
586            # but always include the original data
587            if (fitpage_name in plot.name
588                    or filename in plot.name
589                    or filename == plot.filename):
590                # Residuals get their own plot
591                if plot.plot_role == Data1D.ROLE_RESIDUAL:
592                    plot.yscale='linear'
593                    self.plotData([(item, plot)])
594                else:
595                    new_plots.append((item, plot))
596
597        if new_plots:
598            self.plotData(new_plots)
599
600    def displayData(self, data_list, id=None):
601        """
602        Forces display of charts for the given data set
603        """
604        plot_to_show = data_list[0]
605        # passed plot is used ONLY to figure out its title,
606        # so all the charts related by it can be pulled from
607        # the data explorer indices.
608        filename = plot_to_show.filename
609        self.displayFile(filename=filename, is_data=plot_to_show.is_data, id=id)
610
611    def addDataPlot2D(self, plot_set, item):
612        """
613        Create a new 2D plot and add it to the workspace
614        """
615        plot2D = Plotter2D(self)
616        plot2D.item = item
617        plot2D.plot(plot_set)
618        self.addPlot(plot2D)
619        self.active_plots[plot2D.data.name] = plot2D
620        #============================================
621        # Experimental hook for silx charts
622        #============================================
623        ## Attach silx
624        #from silx.gui import qt
625        #from silx.gui.plot import StackView
626        #sv = StackView()
627        #sv.setColormap("jet", autoscale=True)
628        #sv.setStack(plot_set.data.reshape(1,100,100))
629        ##sv.setLabels(["x: -10 to 10 (200 samples)",
630        ##              "y: -10 to 5 (150 samples)"])
631        #sv.show()
632        #============================================
633
634    def plotData(self, plots, transform=True):
635        """
636        Takes 1D/2D data and generates a single plot (1D) or multiple plots (2D)
637        """
638        # Call show on requested plots
639        # All same-type charts in one plot
640        for item, plot_set in plots:
641            if isinstance(plot_set, Data1D):
642                if not 'new_plot' in locals():
643                    new_plot = Plotter(self)
644                    new_plot.item = item
645                new_plot.plot(plot_set, transform=transform)
646                # active_plots may contain multiple charts
647                self.active_plots[plot_set.name] = new_plot
648            elif isinstance(plot_set, Data2D):
649                self.addDataPlot2D(plot_set, item)
650            else:
651                msg = "Incorrect data type passed to Plotting"
652                raise AttributeError(msg)
653
654        if 'new_plot' in locals() and \
655            hasattr(new_plot, 'data') and \
656            isinstance(new_plot.data, Data1D):
657                self.addPlot(new_plot)
658
659    def newPlot(self):
660        """
661        Select checked data and plot it
662        """
663        # Check which tab is currently active
664        if self.current_view == self.treeView:
665            plots = GuiUtils.plotsFromCheckedItems(self.model)
666        else:
667            plots = GuiUtils.plotsFromCheckedItems(self.theory_model)
668
669        self.plotData(plots)
670
671    def addPlot(self, new_plot):
672        """
673        Helper method for plot bookkeeping
674        """
675        # Update the global plot counter
676        title = str(PlotHelper.idOfPlot(new_plot))
677        new_plot.setWindowTitle(title)
678
679        # Set the object name to satisfy the Squish object picker
680        new_plot.setObjectName(title)
681
682        # Add the plot to the workspace
683        plot_widget = self.parent.workspace().addSubWindow(new_plot)
684
685        # Show the plot
686        new_plot.show()
687        new_plot.canvas.draw()
688
689        # Update the plot widgets dict
690        self.plot_widgets[title]=plot_widget
691
692        # Update the active chart list
693        self.active_plots[new_plot.data.name] = new_plot
694
695    def appendPlot(self):
696        """
697        Add data set(s) to the existing matplotlib chart
698        """
699        # new plot data; check which tab is currently active
700        if self.current_view == self.treeView:
701            new_plots = GuiUtils.plotsFromCheckedItems(self.model)
702        else:
703            new_plots = GuiUtils.plotsFromCheckedItems(self.theory_model)
704
705        # old plot data
706        plot_id = str(self.cbgraph.currentText())
707        try:
708            assert plot_id in PlotHelper.currentPlots(), "No such plot: %s"%(plot_id)
709        except:
710            return
711
712        old_plot = PlotHelper.plotById(plot_id)
713
714        # Add new data to the old plot, if data type is the same.
715        for _, plot_set in new_plots:
716            if type(plot_set) is type(old_plot._data):
717                old_plot.data = plot_set
718                old_plot.plot()
719                # need this for lookup - otherwise this plot will never update
720                self.active_plots[plot_set.name] = old_plot
721
722    def updatePlot(self, data):
723        """
724        Modify existing plot for immediate response and returns True.
725        Returns false, if the plot does not exist already.
726        """
727        try: # there might be a list or a single value being passed
728            data = data[0]
729        except TypeError:
730            pass
731        assert type(data).__name__ in ['Data1D', 'Data2D']
732
733        ids_keys = list(self.active_plots.keys())
734        ids_vals = [val.data.name for val in self.active_plots.values()]
735
736        data_id = data.name
737        if data_id in ids_keys:
738            # We have data, let's replace data that needs replacing
739            if data.plot_role != Data1D.ROLE_DATA:
740                self.active_plots[data_id].replacePlot(data_id, data)
741            return True
742        elif data_id in ids_vals:
743            if data.plot_role != Data1D.ROLE_DATA:
744                list(self.active_plots.values())[ids_vals.index(data_id)].replacePlot(data_id, data)
745            return True
746        return False
747
748    def chooseFiles(self):
749        """
750        Shows the Open file dialog and returns the chosen path(s)
751        """
752        # List of known extensions
753        wlist = self.getWlist()
754
755        # Location is automatically saved - no need to keep track of the last dir
756        # But only with Qt built-in dialog (non-platform native)
757        paths = QtWidgets.QFileDialog.getOpenFileNames(self, "Choose a file", "",
758                wlist, None, QtWidgets.QFileDialog.DontUseNativeDialog)[0]
759        if not paths:
760            return
761
762        if not isinstance(paths, list):
763            paths = [paths]
764
765        return paths
766
767    def readData(self, path):
768        """
769        verbatim copy-paste from
770           sasgui.guiframe.local_perspectives.data_loader.data_loader.py
771        slightly modified for clarity
772        """
773        message = ""
774        log_msg = ''
775        output = {}
776        any_error = False
777        data_error = False
778        error_message = ""
779        number_of_files = len(path)
780        self.communicator.progressBarUpdateSignal.emit(0.0)
781
782        for index, p_file in enumerate(path):
783            basename = os.path.basename(p_file)
784            _, extension = os.path.splitext(basename)
785            if extension.lower() in GuiUtils.EXTENSIONS:
786                any_error = True
787                log_msg = "Data Loader cannot "
788                log_msg += "load: %s\n" % str(p_file)
789                log_msg += """Please try to open that file from "open project" """
790                log_msg += """or "open analysis" menu\n"""
791                error_message = log_msg + "\n"
792                logging.info(log_msg)
793                continue
794
795            try:
796                message = "Loading Data... " + str(basename) + "\n"
797
798                # change this to signal notification in GuiManager
799                self.communicator.statusBarUpdateSignal.emit(message)
800
801                output_objects = self.loader.load(p_file)
802
803                # Some loaders return a list and some just a single Data1D object.
804                # Standardize.
805                if not isinstance(output_objects, list):
806                    output_objects = [output_objects]
807
808                for item in output_objects:
809                    # cast sascalc.dataloader.data_info.Data1D into
810                    # sasgui.guiframe.dataFitting.Data1D
811                    # TODO : Fix it
812                    new_data = self.manager.create_gui_data(item, p_file)
813                    output[new_data.id] = new_data
814
815                    # Model update should be protected
816                    self.mutex.lock()
817                    self.updateModel(new_data, p_file)
818                    #self.model.reset()
819                    QtWidgets.QApplication.processEvents()
820                    self.mutex.unlock()
821
822                    if hasattr(item, 'errors'):
823                        for error_data in item.errors:
824                            data_error = True
825                            message += "\tError: {0}\n".format(error_data)
826                    else:
827
828                        logging.error("Loader returned an invalid object:\n %s" % str(item))
829                        data_error = True
830
831            except Exception as ex:
832                logging.error(sys.exc_info()[1])
833
834                any_error = True
835            if any_error or error_message != "":
836                if error_message == "":
837                    error = "Error: " + str(sys.exc_info()[1]) + "\n"
838                    error += "while loading Data: \n%s\n" % str(basename)
839                    error_message += "The data file you selected could not be loaded.\n"
840                    error_message += "Make sure the content of your file"
841                    error_message += " is properly formatted.\n\n"
842                    error_message += "When contacting the SasView team, mention the"
843                    error_message += " following:\n%s" % str(error)
844                elif data_error:
845                    base_message = "Errors occurred while loading "
846                    base_message += "{0}\n".format(basename)
847                    base_message += "The data file loaded but with errors.\n"
848                    error_message = base_message + error_message
849                else:
850                    error_message += "%s\n" % str(p_file)
851
852            current_percentage = int(100.0* index/number_of_files)
853            self.communicator.progressBarUpdateSignal.emit(current_percentage)
854
855        if any_error or error_message:
856            logging.error(error_message)
857            status_bar_message = "Errors occurred while loading %s" % format(basename)
858            self.communicator.statusBarUpdateSignal.emit(status_bar_message)
859
860        else:
861            message = "Loading Data Complete! "
862        message += log_msg
863        # Notify the progress bar that the updates are over.
864        self.communicator.progressBarUpdateSignal.emit(-1)
865        self.communicator.statusBarUpdateSignal.emit(message)
866
867        return output, message
868
869    def getWlist(self):
870        """
871        Wildcards of files we know the format of.
872        """
873        # Display the Qt Load File module
874        cards = self.loader.get_wildcards()
875
876        # get rid of the wx remnant in wildcards
877        # TODO: modify sasview loader get_wildcards method, after merge,
878        # so this kludge can be avoided
879        new_cards = []
880        for item in cards:
881            new_cards.append(item[:item.find("|")])
882        wlist = ';;'.join(new_cards)
883
884        return wlist
885
886    def setItemsCheckability(self, model, dimension=None, checked=False):
887        """
888        For a given model, check or uncheck all items of given dimension
889        """
890        mode = QtCore.Qt.Checked if checked else QtCore.Qt.Unchecked
891
892        assert isinstance(checked, bool)
893
894        types = (None, Data1D, Data2D)
895        assert dimension in types
896
897        for index in range(model.rowCount()):
898            item = model.item(index)
899            if dimension is not None and not isinstance(GuiUtils.dataFromItem(item), dimension):
900                continue
901            if item.isCheckable() and item.checkState() != mode:
902                item.setCheckState(mode)
903            # look for all children
904            for inner_index in range(item.rowCount()):
905                child = item.child(inner_index)
906                if child.isCheckable() and child.checkState() != mode:
907                    child.setCheckState(mode)
908
909    def selectData(self, index):
910        """
911        Callback method for modifying the TreeView on Selection Options change
912        """
913        if not isinstance(index, int):
914            msg = "Incorrect type passed to DataExplorer.selectData()"
915            raise AttributeError(msg)
916
917        # Respond appropriately
918        if index == 0:
919            self.setItemsCheckability(self.model, checked=True)
920
921        elif index == 1:
922            # De-select All
923            self.setItemsCheckability(self.model, checked=False)
924
925        elif index == 2:
926            # Select All 1-D
927            self.setItemsCheckability(self.model, dimension=Data1D, checked=True)
928
929        elif index == 3:
930            # Unselect All 1-D
931            self.setItemsCheckability(self.model, dimension=Data1D, checked=False)
932
933        elif index == 4:
934            # Select All 2-D
935            self.setItemsCheckability(self.model, dimension=Data2D, checked=True)
936
937        elif index == 5:
938            # Unselect All 2-D
939            self.setItemsCheckability(self.model, dimension=Data2D, checked=False)
940
941        else:
942            msg = "Incorrect value in the Selection Option"
943            # Change this to a proper logging action
944            raise Exception(msg)
945
946    def contextMenu(self):
947        """
948        Define actions and layout of the right click context menu
949        """
950        # Create a custom menu based on actions defined in the UI file
951        self.context_menu = QtWidgets.QMenu(self)
952        self.context_menu.addAction(self.actionDataInfo)
953        self.context_menu.addAction(self.actionSaveAs)
954        self.context_menu.addAction(self.actionQuickPlot)
955        self.context_menu.addSeparator()
956        self.context_menu.addAction(self.actionQuick3DPlot)
957        self.context_menu.addAction(self.actionEditMask)
958        #self.context_menu.addSeparator()
959        #self.context_menu.addAction(self.actionFreezeResults)
960        self.context_menu.addSeparator()
961        self.context_menu.addAction(self.actionDelete)
962
963
964        # Define the callbacks
965        self.actionDataInfo.triggered.connect(self.showDataInfo)
966        self.actionSaveAs.triggered.connect(self.saveDataAs)
967        self.actionQuickPlot.triggered.connect(self.quickDataPlot)
968        self.actionQuick3DPlot.triggered.connect(self.quickData3DPlot)
969        self.actionEditMask.triggered.connect(self.showEditDataMask)
970        self.actionDelete.triggered.connect(self.deleteItem)
971        self.actionFreezeResults.triggered.connect(self.freezeSelectedItems)
972
973    def onCustomContextMenu(self, position):
974        """
975        Show the right-click context menu in the data treeview
976        """
977        index = self.current_view.indexAt(position)
978        proxy = self.current_view.model()
979        model = proxy.sourceModel()
980
981        if not index.isValid():
982            return
983        model_item = model.itemFromIndex(proxy.mapToSource(index))
984        # Find the mapped index
985        orig_index = model_item.isCheckable()
986        if not orig_index:
987            return
988        # Check the data to enable/disable actions
989        is_2D = isinstance(GuiUtils.dataFromItem(model_item), Data2D)
990        self.actionQuick3DPlot.setEnabled(is_2D)
991        self.actionEditMask.setEnabled(is_2D)
992
993        # Freezing
994        # check that the selection has inner items
995        freeze_enabled = False
996        if model_item.parent() is not None:
997            freeze_enabled = True
998        self.actionFreezeResults.setEnabled(freeze_enabled)
999
1000        # Fire up the menu
1001        self.context_menu.exec_(self.current_view.mapToGlobal(position))
1002
1003    def showDataInfo(self):
1004        """
1005        Show a simple read-only text edit with data information.
1006        """
1007        index = self.current_view.selectedIndexes()[0]
1008        proxy = self.current_view.model()
1009        model = proxy.sourceModel()
1010        model_item = model.itemFromIndex(proxy.mapToSource(index))
1011
1012        data = GuiUtils.dataFromItem(model_item)
1013        if isinstance(data, Data1D):
1014            text_to_show = GuiUtils.retrieveData1d(data)
1015            # Hardcoded sizes to enable full width rendering with default font
1016            self.txt_widget.resize(420,600)
1017        else:
1018            text_to_show = GuiUtils.retrieveData2d(data)
1019            # Hardcoded sizes to enable full width rendering with default font
1020            self.txt_widget.resize(700,600)
1021
1022        self.txt_widget.setReadOnly(True)
1023        self.txt_widget.setWindowFlags(QtCore.Qt.Window)
1024        self.txt_widget.setWindowIcon(QtGui.QIcon(":/res/ball.ico"))
1025        self.txt_widget.setWindowTitle("Data Info: %s" % data.filename)
1026        self.txt_widget.clear()
1027        self.txt_widget.insertPlainText(text_to_show)
1028
1029        self.txt_widget.show()
1030        # Move the slider all the way up, if present
1031        vertical_scroll_bar = self.txt_widget.verticalScrollBar()
1032        vertical_scroll_bar.triggerAction(QtWidgets.QScrollBar.SliderToMinimum)
1033
1034    def saveDataAs(self):
1035        """
1036        Save the data points as either txt or xml
1037        """
1038        index = self.current_view.selectedIndexes()[0]
1039        proxy = self.current_view.model()
1040        model = proxy.sourceModel()
1041        model_item = model.itemFromIndex(proxy.mapToSource(index))
1042
1043        data = GuiUtils.dataFromItem(model_item)
1044        if isinstance(data, Data1D):
1045            GuiUtils.saveData1D(data)
1046        else:
1047            GuiUtils.saveData2D(data)
1048
1049    def quickDataPlot(self):
1050        """
1051        Frozen plot - display an image of the plot
1052        """
1053        index = self.current_view.selectedIndexes()[0]
1054        proxy = self.current_view.model()
1055        model = proxy.sourceModel()
1056        model_item = model.itemFromIndex(proxy.mapToSource(index))
1057
1058        data = GuiUtils.dataFromItem(model_item)
1059
1060        method_name = 'Plotter'
1061        if isinstance(data, Data2D):
1062            method_name='Plotter2D'
1063
1064        self.new_plot = globals()[method_name](self, quickplot=True)
1065        self.new_plot.data = data
1066        #new_plot.plot(marker='o')
1067        self.new_plot.plot()
1068
1069        # Update the global plot counter
1070        title = "Plot " + data.name
1071        self.new_plot.setWindowTitle(title)
1072
1073        # Show the plot
1074        self.new_plot.show()
1075
1076    def quickData3DPlot(self):
1077        """
1078        Slowish 3D plot
1079        """
1080        index = self.current_view.selectedIndexes()[0]
1081        proxy = self.current_view.model()
1082        model = proxy.sourceModel()
1083        model_item = model.itemFromIndex(proxy.mapToSource(index))
1084
1085        data = GuiUtils.dataFromItem(model_item)
1086
1087        self.new_plot = Plotter2D(self, quickplot=True, dimension=3)
1088        self.new_plot.data = data
1089        self.new_plot.plot()
1090
1091        # Update the global plot counter
1092        title = "Plot " + data.name
1093        self.new_plot.setWindowTitle(title)
1094
1095        # Show the plot
1096        self.new_plot.show()
1097
1098    def extShowEditDataMask(self):
1099        self.showEditDataMask()
1100
1101    def showEditDataMask(self, data=None):
1102        """
1103        Mask Editor for 2D plots
1104        """
1105        try:
1106            if data is None or not isinstance(data, Data2D):
1107                index = self.current_view.selectedIndexes()[0]
1108                proxy = self.current_view.model()
1109                model = proxy.sourceModel()
1110                model_item = model.itemFromIndex(proxy.mapToSource(index))
1111
1112                data = GuiUtils.dataFromItem(model_item)
1113
1114            if data is None or not isinstance(data, Data2D):
1115                msg = QtWidgets.QMessageBox()
1116                msg.setIcon(QtWidgets.QMessageBox.Information)
1117                msg.setText("Error: cannot apply mask. \
1118                                Please select a 2D dataset.")
1119                msg.setStandardButtons(QtWidgets.QMessageBox.Cancel)
1120                msg.exec_()
1121                return
1122        except:
1123            msg = QtWidgets.QMessageBox()
1124            msg.setIcon(QtWidgets.QMessageBox.Information)
1125            msg.setText("Error: No dataset selected. \
1126                            Please select a 2D dataset.")
1127            msg.setStandardButtons(QtWidgets.QMessageBox.Cancel)
1128            msg.exec_()
1129            return
1130
1131        mask_editor = MaskEditor(self, data)
1132        # Modal dialog here.
1133        mask_editor.exec_()
1134
1135    def freezeItem(self, item=None):
1136        """
1137        Freeze given item
1138        """
1139        if item is None:
1140            return
1141        self.model.beginResetModel()
1142        new_item = self.cloneTheory(item)
1143        self.model.appendRow(new_item)
1144        self.model.endResetModel()
1145
1146    def freezeSelectedItems(self):
1147        """
1148        Freeze selected items
1149        """
1150        indices = self.treeView.selectedIndexes()
1151
1152        proxy = self.treeView.model()
1153        model = proxy.sourceModel()
1154
1155        for index in indices:
1156            row_index = proxy.mapToSource(index)
1157            item_to_copy = model.itemFromIndex(row_index)
1158            if item_to_copy and item_to_copy.isCheckable():
1159                self.freezeItem(item_to_copy)
1160
1161    def deleteItem(self):
1162        """
1163        Delete the current item
1164        """
1165        # Assure this is indeed wanted
1166        delete_msg = "This operation will delete the selected data sets " +\
1167                     "and all the dependents." +\
1168                     "\nDo you want to continue?"
1169        reply = QtWidgets.QMessageBox.question(self,
1170                                           'Warning',
1171                                           delete_msg,
1172                                           QtWidgets.QMessageBox.Yes,
1173                                           QtWidgets.QMessageBox.No)
1174
1175        if reply == QtWidgets.QMessageBox.No:
1176            return
1177
1178        # Every time a row is removed, the indices change, so we'll just remove
1179        # rows and keep calling selectedIndexes until it returns an empty list.
1180        indices = self.current_view.selectedIndexes()
1181
1182        proxy = self.current_view.model()
1183        model = proxy.sourceModel()
1184
1185        deleted_items = []
1186        deleted_names = []
1187
1188        while len(indices) > 0:
1189            index = indices[0]
1190            row_index = proxy.mapToSource(index)
1191            item_to_delete = model.itemFromIndex(row_index)
1192            if item_to_delete and item_to_delete.isCheckable():
1193                row = row_index.row()
1194
1195                # store the deleted item details so we can pass them on later
1196                deleted_names.append(item_to_delete.text())
1197                deleted_items.append(item_to_delete)
1198
1199                # Delete corresponding open plots
1200                self.closePlotsForItem(item_to_delete)
1201
1202                if item_to_delete.parent():
1203                    # We have a child item - delete from it
1204                    item_to_delete.parent().removeRow(row)
1205                else:
1206                    # delete directly from model
1207                    model.removeRow(row)
1208            indices = self.current_view.selectedIndexes()
1209
1210        # Let others know we deleted data
1211        self.communicator.dataDeletedSignal.emit(deleted_items)
1212
1213        # update stored_data
1214        self.manager.update_stored_data(deleted_names)
1215
1216    def closePlotsForItem(self, item):
1217        """
1218        Given standard item, close all its currently displayed plots
1219        """
1220        # item - HashableStandardItems of active plots
1221
1222        # {} -> 'Graph1' : HashableStandardItem()
1223        current_plot_items = {}
1224        for plot_name in PlotHelper.currentPlots():
1225            current_plot_items[plot_name] = PlotHelper.plotById(plot_name).item
1226
1227        # item and its hashable children
1228        items_being_deleted = []
1229        if item.rowCount() > 0:
1230            items_being_deleted = [item.child(n) for n in range(item.rowCount())
1231                                   if isinstance(item.child(n), GuiUtils.HashableStandardItem)]
1232        items_being_deleted.append(item)
1233        # Add the parent in case a child is selected
1234        if isinstance(item.parent(), GuiUtils.HashableStandardItem):
1235            items_being_deleted.append(item.parent())
1236
1237        # Compare plot items and items to delete
1238        plots_to_close = set(current_plot_items.values()) & set(items_being_deleted)
1239
1240        for plot_item in plots_to_close:
1241            for plot_name in current_plot_items.keys():
1242                if plot_item == current_plot_items[plot_name]:
1243                    plotter = PlotHelper.plotById(plot_name)
1244                    # try to delete the plot
1245                    try:
1246                        plotter.close()
1247                        #self.parent.workspace().removeSubWindow(plotter)
1248                        self.plot_widgets[plot_name].close()
1249                        self.plot_widgets.pop(plot_name, None)
1250                    except AttributeError as ex:
1251                        logging.error("Closing of %s failed:\n %s" % (plot_name, str(ex)))
1252
1253        pass # debugger anchor
1254
1255    def onAnalysisUpdate(self, new_perspective=""):
1256        """
1257        Update the perspective combo index based on passed string
1258        """
1259        assert new_perspective in Perspectives.PERSPECTIVES.keys()
1260        self.cbFitting.blockSignals(True)
1261        self.cbFitting.setCurrentIndex(self.cbFitting.findText(new_perspective))
1262        self.cbFitting.blockSignals(False)
1263        pass
1264
1265    def loadComplete(self, output):
1266        """
1267        Post message to status bar and update the data manager
1268        """
1269        assert isinstance(output, tuple)
1270
1271        # Reset the model so the view gets updated.
1272        #self.model.reset()
1273        self.communicator.progressBarUpdateSignal.emit(-1)
1274
1275        output_data = output[0]
1276        message = output[1]
1277        # Notify the manager of the new data available
1278        self.communicator.statusBarUpdateSignal.emit(message)
1279        self.communicator.fileDataReceivedSignal.emit(output_data)
1280        self.manager.add_data(data_list=output_data)
1281
1282    def loadFailed(self, reason):
1283        print("File Load Failed with:\n", reason)
1284        pass
1285
1286    def updateModel(self, data, p_file):
1287        """
1288        Add data and Info fields to the model item
1289        """
1290        # Structure of the model
1291        # checkbox + basename
1292        #     |-------> Data.D object
1293        #     |-------> Info
1294        #                 |----> Title:
1295        #                 |----> Run:
1296        #                 |----> Type:
1297        #                 |----> Path:
1298        #                 |----> Process
1299        #                          |-----> process[0].name
1300        #     |-------> THEORIES
1301
1302        # Top-level item: checkbox with label
1303        checkbox_item = GuiUtils.HashableStandardItem()
1304        checkbox_item.setCheckable(True)
1305        checkbox_item.setCheckState(QtCore.Qt.Checked)
1306        checkbox_item.setText(os.path.basename(p_file))
1307
1308        # Add the actual Data1D/Data2D object
1309        object_item = GuiUtils.HashableStandardItem()
1310        object_item.setData(data)
1311
1312        checkbox_item.setChild(0, object_item)
1313
1314        # Add rows for display in the view
1315        info_item = GuiUtils.infoFromData(data)
1316
1317        # Set info_item as the first child
1318        checkbox_item.setChild(1, info_item)
1319
1320        # Caption for the theories
1321        checkbox_item.setChild(2, QtGui.QStandardItem("FIT RESULTS"))
1322
1323        # New row in the model
1324        self.model.beginResetModel()
1325        self.model.appendRow(checkbox_item)
1326        self.model.endResetModel()
1327
1328    def updateModelFromPerspective(self, model_item):
1329        """
1330        Receive an update model item from a perspective
1331        Make sure it is valid and if so, replace it in the model
1332        """
1333        # Assert the correct type
1334        if not isinstance(model_item, QtGui.QStandardItem):
1335            msg = "Wrong data type returned from calculations."
1336            raise AttributeError(msg)
1337
1338        # TODO: Assert other properties
1339
1340        # Reset the view
1341        ##self.model.reset()
1342        # Pass acting as a debugger anchor
1343        pass
1344
1345    def updateTheoryFromPerspective(self, model_item):
1346        """
1347        Receive an update theory item from a perspective
1348        Make sure it is valid and if so, replace/add in the model
1349        """
1350        # Assert the correct type
1351        if not isinstance(model_item, QtGui.QStandardItem):
1352            msg = "Wrong data type returned from calculations."
1353            raise AttributeError(msg)
1354
1355        # Check if there are any other items for this tab
1356        # If so, delete them
1357        current_tab_name = model_item.text()
1358        for current_index in range(self.theory_model.rowCount()):
1359            #if current_tab_name in self.theory_model.item(current_index).text():
1360            if current_tab_name == self.theory_model.item(current_index).text():
1361                self.theory_model.removeRow(current_index)
1362                break
1363
1364        # send in the new item
1365        self.theory_model.appendRow(model_item)
1366
1367    def deleteIntermediateTheoryPlotsByModelID(self, model_id):
1368        """Given a model's ID, deletes all items in the theory item model which reference the same ID. Useful in the
1369        case of intermediate results disappearing when changing calculations (in which case you don't want them to be
1370        retained in the list)."""
1371        items_to_delete = []
1372        for r in range(self.theory_model.rowCount()):
1373            item = self.theory_model.item(r, 0)
1374            data = item.child(0).data()
1375            if not hasattr(data, "id"):
1376                return
1377            match = GuiUtils.theory_plot_ID_pattern.match(data.id)
1378            if match:
1379                item_model_id = match.groups()[-1]
1380                if item_model_id == model_id:
1381                    # Only delete those identified as an intermediate plot
1382                    if match.groups()[2] not in (None, ""):
1383                        items_to_delete.append(item)
1384
1385        for item in items_to_delete:
1386            self.theory_model.removeRow(item.row())
Note: See TracBrowser for help on using the repository browser.