source: orange-bioinformatics/_bioinformatics/widgets/OWSelectGenes.py @ 1859:6119125fa435

Revision 1859:6119125fa435, 23.4 KB checked in by Ales Erjavec <ales.erjavec@…>, 7 months ago (diff)

Added report.

Line 
1import re
2import unicodedata
3from collections import defaultdict, namedtuple
4from xml.sax.saxutils import escape
5
6from contextlib import contextmanager
7
8from PyQt4.QtGui import (
9    QLabel, QWidget, QPlainTextEdit, QSyntaxHighlighter, QTextCharFormat,
10    QTextCursor, QCompleter, QStringListModel, QListView
11)
12
13from PyQt4.QtCore import Qt, QEvent, pyqtSignal as Signal
14
15import Orange
16
17from Orange.OrangeWidgets.OWWidget import *
18from Orange.OrangeWidgets.OWItemModels import VariableListModel
19from Orange.OrangeWidgets import OWGUI
20
21
22NAME = "Select Genes"
23DESCRIPTION = "Select a specified subset of the input genes."
24ICON = "icons/SelectGenes.svg"
25
26INPUTS = [("Data", Orange.data.Table, "setData")]
27OUTPUTS = [("Selected Data", Orange.data.Table)]
28
29
30def toString(variant):
31    if isinstance(variant, QVariant):
32        return unicode(variant.toString())
33    else:
34        return unicode(variant)
35
36
37def toBool(variant):
38    if isinstance(variant, QVariant):
39        return bool(variant.toPyObject())
40    else:
41        return bool(variant)
42
43
44class SaveSlot(QStandardItem):
45    ModifiedRole = next(OWGUI.OrangeUserRole)
46
47    def __init__(self, name, savedata=None, modified=False):
48        super(SaveSlot, self).__init__(name)
49
50        self.savedata = savedata
51        self.modified = modified
52        self.document = None
53
54    @property
55    def name(self):
56        return unicode(self.text())
57
58    @property
59    def modified(self):
60        return toBool(self.data(SaveSlot.ModifiedRole))
61
62    @modified.setter
63    def modified(self, state):
64        self.setData(bool(state), SaveSlot.ModifiedRole)
65
66
67class SavedSlotDelegate(QStyledItemDelegate):
68
69    def paint(self, painter, option, index):
70        option = QStyleOptionViewItemV4(option)
71        self.initStyleOption(option, index)
72
73        modified = toBool(index.data(SaveSlot.ModifiedRole))
74        if modified:
75            option.palette.setColor(QPalette.Text, QColor(Qt.red))
76            option.palette.setColor(QPalette.Highlight, QColor(Qt.darkRed))
77            option.text = "*" + option.text
78
79        if option.widget:
80            widget = option.widget
81            style = widget.style()
82        else:
83            widget = None
84            style = QApplication.style()
85
86        style.drawControl(QStyle.CE_ItemViewItem, option, painter, widget)
87
88
89class OWSelectGenes(OWWidget):
90
91    contextHandlers = {
92        "": DomainContextHandler(
93            "", ["geneIndex"]
94        )
95    }
96
97    settingsList = ["autoCommit", "preserveOrder", "savedSelections",
98                    "selectedSelectionIndex"]
99
100    def __init__(self, parent=None, signalManager=None, title=NAME):
101        OWWidget.__init__(self, parent, signalManager, title,
102                          wantMainArea=False)
103
104        self.geneIndex = None
105        self.autoCommit = False
106        self.preserveOrder = True
107        self.savedSelections = [
108            ("Example", ["MRE11A", "RAD51", "MLH1", "MSH2", "DMC1"])
109        ]
110
111        self.selectedSelectionIndex = -1
112
113        self.loadSettings()
114
115        # Input variables that could contain names
116        self.variables = VariableListModel()
117        # All gene names from the input (in self.geneIndex column)
118        self.geneNames = []
119        # Output changed flag
120        self._changedFlag = False
121        self.data = None
122        # Current gene names
123        self.selection = []
124
125        box = OWGUI.widgetBox(self.controlArea, "Gene Attribute")
126        self.attrsCombo = OWGUI.comboBox(
127            box, self, "geneIndex",
128            callback=self._onGeneIndexChanged,
129            tooltip="Column with gene names"
130        )
131        self.attrsCombo.setModel(self.variables)
132
133        box = OWGUI.widgetBox(self.controlArea, "Gene Selection")
134        self.entryField = ListTextEdit(box)
135        self.entryField.setTabChangesFocus(True)
136        self.entryField.setToolTip("Enter selected gene names")
137        self.entryField.setDocument(self._createDocument())
138        self.entryField.itemsChanged.connect(self._onItemsChanged)
139
140        box.layout().addWidget(self.entryField)
141
142        completer = ListCompleter()
143        completer.setCompletionMode(QCompleter.PopupCompletion)
144        completer.setCaseSensitivity(Qt.CaseInsensitive)
145        completer.setMaxVisibleItems(10)
146        completer.popup().setAlternatingRowColors(True)
147        completer.setModel(QStringListModel([], self))
148
149        self.entryField.setCompleter(completer)
150
151        box = OWGUI.widgetBox(self.controlArea, "Saved Selections")
152        box.layout().setSpacing(1)
153
154        self.selectionsModel = QStandardItemModel()
155        self.selectionsView = QListView()
156        self.selectionsView.setAlternatingRowColors(True)
157        self.selectionsView.setModel(self.selectionsModel)
158        self.selectionsView.setItemDelegate(SavedSlotDelegate(self))
159        self.selectionsView.selectionModel().selectionChanged.connect(
160            self._onSelectedSaveSlotChanged
161        )
162
163        box.layout().addWidget(self.selectionsView)
164
165        self.actionSave = QAction("Save", self)
166        self.actionAdd = QAction("+", self)
167        self.actionRemove = QAction("-", self)
168
169        toolbar = QFrame()
170        layout = QHBoxLayout()
171        layout.setContentsMargins(0, 0, 0, 0)
172        layout.setSpacing(1)
173
174        def button(action):
175            b = QToolButton()
176            b.setDefaultAction(action)
177            return b
178
179        b = button(self.actionAdd)
180        layout.addWidget(b)
181
182        b = button(self.actionSave)
183        b.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
184        layout.addWidget(b, stretch=10)
185
186        b = button(self.actionRemove)
187        layout.addWidget(b)
188
189        toolbar.setLayout(layout)
190
191        box.layout().addWidget(toolbar)
192
193        self.actionSave.triggered.connect(self.saveSelection)
194        self.actionAdd.triggered.connect(self.addSelection)
195        self.actionRemove.triggered.connect(self.removeSelection)
196
197        box = OWGUI.widgetBox(self.controlArea, "Output")
198        OWGUI.checkBox(box, self, "preserveOrder", "Preserve input order",
199                       tooltip="Preserve the order of the input data "
200                               "instances.",
201                       callback=self.invalidateOutput)
202        cb = OWGUI.checkBox(box, self, "autoCommit", "Auto commit")
203        button = OWGUI.button(box, self, "Commit", callback=self.commit)
204
205        OWGUI.setStopper(self, button, cb, "_changedFlag", self.commit)
206
207        # restore saved selections model.
208        for name, names in self.savedSelections:
209            item = SaveSlot(name, names)
210            self.selectionsModel.appendRow([item])
211
212        if self.selectedSelectionIndex != -1:
213            self.selectionsView.selectionModel().select(
214                self.selectionsModel.index(self.selectedSelectionIndex, 0),
215                QItemSelectionModel.Select
216            )
217        self._updateActions()
218
219    def setData(self, data):
220        """
221        Set the input data.
222        """
223        self.closeContext("")
224        self.warning()
225        self.data = data
226        if data is not None:
227            attrs = gene_candidates(data)
228            self.variables[:] = attrs
229            self.attrsCombo.setCurrentIndex(0)
230            if attrs:
231                self.geneIndex = 0
232            else:
233                self.geneIndex = -1
234                self.warning(0, "No suitable columns for gene names.")
235        else:
236            self.variables[:] = []
237            self.geneIndex = -1
238
239        self._changedFlag = True
240        self._updateCompletionModel()
241
242        self.openContext("", data)
243
244        self.commit()
245
246    @property
247    def geneVar(self):
248        """
249        Current gene attribute or None if none available.
250        """
251        if self.data is not None and self.geneIndex >= 0:
252            return self.variables[self.geneIndex]
253        else:
254            return None
255
256    def invalidateOutput(self):
257        if self.autoCommit:
258            self.commit()
259        else:
260            self._changedFlag = True
261
262    def commit(self):
263        """
264        Send the selected data subset to the output.
265        """
266        gene = self.geneVar
267
268        if gene is not None:
269            data = select_by_genes(self.data, gene,
270                                   gene_list=self.selection,
271                                   preserve_order=self.preserveOrder)
272        else:
273            data = None
274
275        self.send("Selected Data", data)
276        self._changedFlag = False
277
278    def _updateCompletionModel(self):
279        var = self.geneVar
280        if var is not None:
281            names = [str(inst[var]) for inst in self.data
282                     if not inst[var].isSpecial()]
283        else:
284            names = []
285
286        self.geneNames = names
287        self.entryField.completer().model().setStringList(sorted(set(names)))
288        self.entryField.document().highlighter.setNames(names)
289
290    def _onGeneIndexChanged(self):
291        self._updateCompletionModel()
292        self.invalidateOutput()
293
294    def _onItemsChanged(self, names):
295        selection = set(names).intersection(self.geneNames)
296        curr_selection = set(self.selection).intersection(self.geneNames)
297
298        self.selection = names
299
300        if selection != curr_selection:
301            self.invalidateOutput()
302            to_complete = sorted(set(self.geneNames) - set(names))
303            self.entryField.completer().model().setStringList(to_complete)
304
305        item = self._selectedSaveSlot()
306        if item:
307            item.modified = item.savedata != names
308
309    def _selectedSaveSlot(self):
310        """
311        Return the current selected saved selection slot.
312        """
313        indexes = self.selectionsView.selectedIndexes()
314        if indexes:
315            return self.selectionsModel.item(indexes[0].row())
316        else:
317            return None
318
319    def saveSelection(self):
320        """
321        Save (update) the items in the current selected selection.
322        """
323        item = self._selectedSaveSlot()
324        if item:
325            item.savedata = self.entryField.items()
326            item.modified = False
327
328    def addSelection(self, name=None):
329        """
330        Add a new saved selection entry initialized by the current items.
331
332        The new slot will be selected.
333
334        """
335        item = SaveSlot(name or "New selection")
336        item.savedata = self.entryField.items()
337        self.selectionsModel.appendRow([item])
338        self.selectionsView.setCurrentIndex(item.index())
339
340        if not name:
341            self.selectionsView.edit(item.index())
342
343    def removeSelection(self):
344        """
345        Remove the current selected save slot.
346        """
347        item = self._selectedSaveSlot()
348        if item:
349            self.selectionsModel.removeRow(item.row())
350
351    def _onSelectedSaveSlotChanged(self):
352        item = self._selectedSaveSlot()
353        if item:
354            if not item.document:
355                item.document = self._createDocument()
356                if item.savedata:
357                    item.document.setPlainText(" ".join(item.savedata))
358
359            item.document.highlighter.setNames(self.geneNames)
360
361            self.entryField.setDocument(item.document)
362
363        self._updateActions()
364
365    def _createDocument(self):
366        """
367        Create and new QTextDocument instance for editing gene names.
368        """
369        doc = QTextDocument(self)
370        doc.setDocumentLayout(QPlainTextDocumentLayout(doc))
371        doc.highlighter = NameHighlight(doc)
372        return doc
373
374    def _updateActions(self):
375        """
376        Update the Save/remove action enabled state.
377        """
378        selected = bool(self._selectedSaveSlot())
379        self.actionRemove.setEnabled(selected)
380        self.actionSave.setEnabled(selected)
381
382    def getSettings(self, *args, **kwargs):
383        # copy the saved selections model back to widget settings.
384        selections = []
385        for i in range(self.selectionsModel.rowCount()):
386            item = self.selectionsModel.item(i)
387            selections.append((item.name, item.savedata))
388        self.savedSelections = selections
389
390        item = self._selectedSaveSlot()
391        if item is None:
392            self.selectedSelectionIndex = -1
393        else:
394            self.selectedSelectionIndex = item.row()
395
396        return OWWidget.getSettings(self, *args, **kwargs)
397
398    def sendReport(self):
399        report = []
400        if self.data is not None:
401            report.append("%i instances on input." % len(self.data))
402        else:
403            report.append("No data on input.")
404
405        if self.geneVar is not None:
406            report.append("Gene names taken from %r attribute." %
407                          escape(self.geneVar.name))
408
409        self.reportSection("Input")
410        self.startReportList()
411        for item in report:
412            self.addToReportList(item)
413        self.finishReportList()
414        self.reportRaw(
415            "<p>Gene Selection: %s</p>" %
416            escape(" ".join(self.selection))
417        )
418        self.reportSettings(
419            "Settings",
420            [("Preserve order", self.preserveOrder)]
421        )
422
423
424def is_string(feature):
425    return isinstance(feature, Orange.feature.String)
426
427
428def domain_variables(domain):
429    """
430    Return all feature descriptors from the domain.
431    """
432    vars = (domain.features +
433            domain.class_vars +
434            domain.getmetas().values())
435    return vars
436
437
438def gene_candidates(data):
439    """
440    Return features that could contain gene names.
441    """
442    vars = domain_variables(data.domain)
443    vars = filter(is_string, vars)
444    return vars
445
446
447def select_by_genes(data, gene_feature, gene_list, preserve_order=True):
448    if preserve_order:
449        selection = set(gene_list)
450        sel = [inst for inst in data
451               if str(inst[gene_feature]) in selection]
452    else:
453        by_genes = defaultdict(list)
454        for inst in data:
455            by_genes[str(inst[gene_feature])].append(inst)
456
457        sel = []
458        for name in gene_list:
459            sel.extend(by_genes.get(name, []))
460
461    if sel:
462        data = Orange.data.Table(data.domain, sel)
463    else:
464        data = Orange.data.Table(data.domain)
465
466    return data
467
468
469_CompletionState = namedtuple(
470    "_CompletionState",
471    ["start",  # completion prefix start position
472     "pos",  # cursor position
473     "anchor"]  # anchor position (inline completion end)
474)
475
476
477class ListTextEdit(QPlainTextEdit):
478    """
479    A text editor specialized for editing a list of items.
480    """
481    #: Emitted when the list items change.
482    itemsChanged = Signal(list)
483
484    def __init__(self, parent=None, **kwargs):
485        QPlainTextEdit.__init__(self, parent, **kwargs)
486
487        self._items = None
488        self._completer = None
489        self._completionState = _CompletionState(-1, -1, -1)
490
491        self.cursorPositionChanged.connect(self._cursorPositionChanged)
492        self.textChanged.connect(self._textChanged)
493
494    def setCompleter(self, completer):
495        """
496        Set a completer for list items.
497        """
498        if self._completer is not None:
499            self._completer.setWidget(None)
500            self._completer.activated.disconnect(self._insertCompletion)
501
502        self._completer = completer
503
504        if self._completer:
505            self._completer.setWidget(self)
506            self._completer.activated.connect(self._insertCompletion)
507
508    def completer(self):
509        """
510        Return the completer.
511        """
512        return self._completer
513
514    def setItems(self, items):
515        text = " ".join(items)
516        self.setPlainText(text)
517
518    def items(self):
519        if self._items is None:
520            self._items = self._getItems()
521        return self._items
522
523    def keyPressEvent(self, event):
524        # TODO: in Qt 4.8 QPlainTextEdit uses inputMethodEvent for
525        # non-ascii input
526
527        if self._completer.popup().isVisible():
528            if event.key() in [Qt.Key_Enter, Qt.Key_Return, Qt.Key_Escape,
529                               Qt.Key_Tab, Qt.Key_Backtab]:
530                # These need to propagate to the completer.
531                event.ignore()
532                return
533
534        QPlainTextEdit.keyPressEvent(self, event)
535
536        if not len(event.text()) or not is_printable(unicode(event.text())[0]):
537            return
538
539        text = unicode(self.toPlainText())
540        cursor = self.textCursor()
541        pos = cursor.position()
542
543        if pos == len(text) or not(text[pos].strip()):
544            # cursor is at end of text or whitespace
545            # find the beginning of the current word
546            whitespace = " \t\n\r\f\v"
547            start = max([text.rfind(c, 0, pos) for c in whitespace]) + 1
548
549            prefix = text[start:pos]
550
551            if prefix:
552                if self._completer.completionPrefix() != prefix:
553                    self._completer.setCompletionPrefix(text[start:pos])
554
555                rect = self.cursorRect()
556                popup = self._completer.popup()
557                if popup.isVisible():
558                    rect.setWidth(popup.width())
559                else:
560                    rect.setWidth(popup.sizeHintForColumn(0) +
561                                  popup.verticalScrollBar().sizeHint().width())
562
563                # Popup the completer list
564                self._completer.complete(rect)
565
566                # Inline completion of a common prefix
567                inline = self._commonCompletionPrefix()
568                inline = inline[len(prefix):]
569
570                self._completionState = \
571                    _CompletionState(start, pos, pos + len(inline))
572
573                cursor.insertText(inline)
574                cursor.setPosition(pos, QTextCursor.KeepAnchor)
575                self.setTextCursor(cursor)
576
577            elif self._completer.popup().isVisible():
578                self._stopCompletion()
579
580    def _cursorPositionChanged(self):
581        cursor = self.textCursor()
582        pos = cursor.position()
583        start, _, _ = self._completionState
584
585        if start == -1:
586            # completion not in progress
587            return
588
589        if pos <= start:
590            # cursor moved before the start of the prefix
591            self._stopCompletion()
592            return
593
594        text = unicode(self.toPlainText())
595        # Find the end of the word started by completion prefix
596        word_end = len(text)
597        for i in range(start, len(text)):
598            if text[i] in " \t\n\r\f\v":
599                word_end = i
600                break
601
602        if pos > word_end:
603            # cursor moved past the word boundary
604            self._stopCompletion()
605
606        # TODO: Update the prefix when moving the cursor
607        # inside the word
608
609    def _insertCompletion(self, item):
610        if isinstance(item, list):
611            completion = " ".join(item)
612        else:
613            completion = unicode(item)
614
615        start, _, end = self._completionState
616
617        self._stopCompletion()
618
619        cursor = self.textCursor()
620        # Replace the prefix+inline with the full completion
621        # (correcting for the case-insensitive search).
622        cursor.setPosition(min(end, self.document().characterCount()))
623        cursor.setPosition(start, QTextCursor.KeepAnchor)
624
625        cursor.insertText(completion + " ")
626
627    def _commonCompletionPrefix(self):
628        """
629        Return the common prefix of items in the current completion model.
630        """
631        model = self._completer.completionModel()
632        column = self._completer.completionColumn()
633        role = self._completer.completionRole()
634        items = [toString(model.index(i, column).data(role))
635                 for i in range(model.rowCount())]
636
637        if not items:
638            return ""
639
640        first = min(items)
641        last = max(items)
642        for i, c in enumerate(first):
643            if c != last[i]:
644                return first[:i]
645
646        return first
647
648    def _stopCompletion(self):
649        self._completionState = _CompletionState(-1, -1, -1)
650        if self._completer.popup().isVisible():
651            self._completer.popup().hide()
652
653    def _textChanged(self):
654        items = self._getItems()
655        if self._items != items:
656            self._items = items
657            self.itemsChanged.emit(items)
658
659    def _getItems(self):
660        """
661        Return the current items (a list of strings).
662
663        .. note:: The inline completion text is not included.
664
665        """
666        text = unicode(self.toPlainText())
667        if self._completionState[0] != -1:
668            # Remove the inline completion text
669            _, pos, end = self._completionState
670            text = text[:pos] + text[end:]
671        return [item for item in text.split() if item.strip()]
672
673
674class NameHighlight(QSyntaxHighlighter):
675    def __init__(self, parent=None, **kwargs):
676        super(NameHighlight, self).__init__(parent, **kwargs)
677
678        self._names = set()
679
680        self._format = QTextCharFormat()
681        self._format.setForeground(Qt.blue)
682
683        self._unrecognized_format = QTextCharFormat()
684#         self._unrecognized_format.setFontStrikeOut(True)
685
686    def setNames(self, names):
687        self._names = set(names)
688        self.rehighlight()
689
690    def names(self):
691        return set(self._names)
692
693    def highlightBlock(self, text):
694        text = unicode(text)
695        pattern = re.compile(r"\S+")
696        for match in pattern.finditer(text):
697            name = text[match.start(): match.end()]
698            match_len = match.end() - match.start()
699
700            if not name.strip():
701                continue
702
703            if name in self._names:
704                format = self._format
705            else:
706                format = self._unrecognized_format
707
708            self.setFormat(match.start(), match_len, format)
709
710
711@contextmanager
712def signals_blocked(obj):
713    blocked = obj.signalsBlocked()
714    obj.blockSignals(True)
715    try:
716        yield
717    finally:
718        obj.blockSignals(blocked)
719
720
721class ListCompleter(QCompleter):
722    """
723    A completer supporting selection of multiple list items.
724    """
725    activated = Signal(list)
726
727    def __init__(self, *args, **kwargs):
728        QCompleter.__init__(self, *args, **kwargs)
729
730        popup = QListView()
731        popup.setEditTriggers(QListView.NoEditTriggers)
732        popup.setSelectionMode(QListView.ExtendedSelection)
733
734        self.setPopup(popup)
735
736    def setPopup(self, popup):
737        QCompleter.setPopup(self, popup)
738
739        popup.viewport().installEventFilter(self)
740        popup.doubleClicked.connect(self._complete)
741
742    def eventFilter(self, receiver, event):
743        if event.type() == QEvent.KeyPress and receiver is self.popup():
744            if event.key() in [Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab]:
745                self._complete()
746                return True
747
748        elif event.type() == QEvent.MouseButtonRelease and \
749                receiver is self.popup().viewport():
750            # Process the event without emitting 'clicked', ... signal to
751            # override the default QCompleter behavior
752            with signals_blocked(self.popup()):
753                QApplication.sendEvent(self.popup(), event)
754                return True
755
756        return QCompleter.eventFilter(self, receiver, event)
757
758    def _complete(self):
759        selection = self.popup().selectionModel().selection()
760        indexes = selection.indexes()
761
762        items = [toString(index.data(self.completionRole()))
763                 for index in indexes]
764
765        if self.popup().isVisible():
766            self.popup().hide()
767
768        if items:
769            self.activated.emit(items)
770
771
772# All control character categories.
773_control = set(["Cc", "Cf", "Cs", "Co", "Cn"])
774
775
776def is_printable(unichar):
777    """
778    Return True if the unicode character `unichar` is a printable character.
779    """
780    return unicodedata.category(unichar) not in _control
781
782
783def test():
784    app = QApplication([])
785    w = OWSelectGenes()
786    data = Orange.data.Table("brown-selected")
787    w.setData(data)
788    w.show()
789    app.exec_()
790    w.saveSettings()
791    w.deleteLater()
792    del w
793    app.processEvents()
794
795if __name__ == "__main__":
796    test()
Note: See TracBrowser for help on using the repository browser.