source: orange-bioinformatics/_bioinformatics/widgets/OWSelectGenes.py @ 1857:abf1cbef7844

Revision 1857:abf1cbef7844, 21.8 KB checked in by Ales Erjavec <ales.erjavec@…>, 7 months ago (diff)

Added saved selections to the Select Genes widget.

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