source: sasview/src/sas/qtgui/MainWindow/DataExplorer.py @ 855e7ad

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 855e7ad was 855e7ad, checked in by Piotr Rozyczko <rozyczko@…>, 6 years ago

Visibility of P® and I(Q) charts for inversion SASVIEW-995

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