source: orange-bioinformatics/_bioinformatics/widgets/OWSelectGenes.py @ 1858:c5c08ee1c3d2

Revision 1858:c5c08ee1c3d2, 22.6 KB checked in by Ales Erjavec <ales.erjavec@…>, 6 months ago (diff)

Added some doc strings.

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, "setData")]
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 setData(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        """
248        Current gene attribute or None if none available.
249        """
250        if self.data is not None and self.geneIndex >= 0:
251            return self.variables[self.geneIndex]
252        else:
253            return None
254
255    def invalidateOutput(self):
256        if self.autoCommit:
257            self.commit()
258        else:
259            self._changedFlag = True
260
261    def commit(self):
262        """
263        Send the selected data subset to the output.
264        """
265        gene = self.geneVar
266
267        if gene is not None:
268            data = select_by_genes(self.data, gene,
269                                   gene_list=self.selection,
270                                   preserve_order=self.preserveOrder)
271        else:
272            data = None
273
274        self.send("Selected Data", data)
275        self._changedFlag = False
276
277    def _updateCompletionModel(self):
278        var = self.geneVar
279        if var is not None:
280            names = [str(inst[var]) for inst in self.data
281                     if not inst[var].isSpecial()]
282        else:
283            names = []
284
285        self.geneNames = names
286        self.entryField.completer().model().setStringList(sorted(set(names)))
287        self.entryField.document().highlighter.setNames(names)
288
289    def _onGeneIndexChanged(self):
290        self._updateCompletionModel()
291        self.invalidateOutput()
292
293    def _onItemsChanged(self, names):
294        selection = set(names).intersection(self.geneNames)
295        curr_selection = set(self.selection).intersection(self.geneNames)
296
297        self.selection = names
298
299        if selection != curr_selection:
300            self.invalidateOutput()
301            to_complete = sorted(set(self.geneNames) - set(names))
302            self.entryField.completer().model().setStringList(to_complete)
303
304        item = self._selectedSaveSlot()
305        if item:
306            item.modified = item.savedata != names
307
308    def _selectedSaveSlot(self):
309        """
310        Return the current selected saved selection slot.
311        """
312        indexes = self.selectionsView.selectedIndexes()
313        if indexes:
314            return self.selectionsModel.item(indexes[0].row())
315        else:
316            return None
317
318    def saveSelection(self):
319        """
320        Save (update) the items in the current selected selection.
321        """
322        item = self._selectedSaveSlot()
323        if item:
324            item.savedata = self.entryField.items()
325            item.modified = False
326
327    def addSelection(self, name=None):
328        """
329        Add a new saved selection entry initialized by the current items.
330
331        The new slot will be selected.
332
333        """
334        item = SaveSlot(name or "New selection")
335        item.savedata = self.entryField.items()
336        self.selectionsModel.appendRow([item])
337        self.selectionsView.setCurrentIndex(item.index())
338
339        if not name:
340            self.selectionsView.edit(item.index())
341
342    def removeSelection(self):
343        """
344        Remove the current selected save slot.
345        """
346        item = self._selectedSaveSlot()
347        if item:
348            self.selectionsModel.removeRow(item.row())
349
350    def _onSelectedSaveSlotChanged(self):
351        item = self._selectedSaveSlot()
352        if item:
353            if not item.document:
354                item.document = self._createDocument()
355                if item.savedata:
356                    item.document.setPlainText(" ".join(item.savedata))
357
358            item.document.highlighter.setNames(self.geneNames)
359
360            self.entryField.setDocument(item.document)
361
362        self._updateActions()
363
364    def _createDocument(self):
365        """
366        Create and new QTextDocument instance for editing gene names.
367        """
368        doc = QTextDocument(self)
369        doc.setDocumentLayout(QPlainTextDocumentLayout(doc))
370        doc.highlighter = NameHighlight(doc)
371        return doc
372
373    def _updateActions(self):
374        """
375        Update the Save/remove action enabled state.
376        """
377        selected = bool(self._selectedSaveSlot())
378        self.actionRemove.setEnabled(selected)
379        self.actionSave.setEnabled(selected)
380
381    def getSettings(self, *args, **kwargs):
382        # copy the saved selections model back to widget settings.
383        selections = []
384        for i in range(self.selectionsModel.rowCount()):
385            item = self.selectionsModel.item(i)
386            selections.append((item.name, item.savedata))
387        self.savedSelections = selections
388
389        item = self._selectedSaveSlot()
390        if item is None:
391            self.selectedSelectionIndex = -1
392        else:
393            self.selectedSelectionIndex = item.row()
394
395        return OWWidget.getSettings(self, *args, **kwargs)
396
397
398def is_string(feature):
399    return isinstance(feature, Orange.feature.String)
400
401
402def domain_variables(domain):
403    """
404    Return all feature descriptors from the domain.
405    """
406    vars = (domain.features +
407            domain.class_vars +
408            domain.getmetas().values())
409    return vars
410
411
412def gene_candidates(data):
413    """
414    Return features that could contain gene names.
415    """
416    vars = domain_variables(data.domain)
417    vars = filter(is_string, vars)
418    return vars
419
420
421def select_by_genes(data, gene_feature, gene_list, preserve_order=True):
422    if preserve_order:
423        selection = set(gene_list)
424        sel = [inst for inst in data
425               if str(inst[gene_feature]) in selection]
426    else:
427        by_genes = defaultdict(list)
428        for inst in data:
429            by_genes[str(inst[gene_feature])].append(inst)
430
431        sel = []
432        for name in gene_list:
433            sel.extend(by_genes.get(name, []))
434
435    if sel:
436        data = Orange.data.Table(data.domain, sel)
437    else:
438        data = Orange.data.Table(data.domain)
439
440    return data
441
442
443_CompletionState = namedtuple(
444    "_CompletionState",
445    ["start",  # completion prefix start position
446     "pos",  # cursor position
447     "anchor"]  # anchor position (inline completion end)
448)
449
450
451class ListTextEdit(QPlainTextEdit):
452    """
453    A text editor specialized for editing a list of items.
454    """
455    #: Emitted when the list items change.
456    itemsChanged = Signal(list)
457
458    def __init__(self, parent=None, **kwargs):
459        QPlainTextEdit.__init__(self, parent, **kwargs)
460
461        self._items = None
462        self._completer = None
463        self._completionState = _CompletionState(-1, -1, -1)
464
465        self.cursorPositionChanged.connect(self._cursorPositionChanged)
466        self.textChanged.connect(self._textChanged)
467
468    def setCompleter(self, completer):
469        """
470        Set a completer for list items.
471        """
472        if self._completer is not None:
473            self._completer.setWidget(None)
474            self._completer.activated.disconnect(self._insertCompletion)
475
476        self._completer = completer
477
478        if self._completer:
479            self._completer.setWidget(self)
480            self._completer.activated.connect(self._insertCompletion)
481
482    def completer(self):
483        """
484        Return the completer.
485        """
486        return self._completer
487
488    def setItems(self, items):
489        text = " ".join(items)
490        self.setPlainText(text)
491
492    def items(self):
493        if self._items is None:
494            self._items = self._getItems()
495        return self._items
496
497    def keyPressEvent(self, event):
498        # TODO: in Qt 4.8 QPlainTextEdit uses inputMethodEvent for
499        # non-ascii input
500
501        if self._completer.popup().isVisible():
502            if event.key() in [Qt.Key_Enter, Qt.Key_Return, Qt.Key_Escape,
503                               Qt.Key_Tab, Qt.Key_Backtab]:
504                # These need to propagate to the completer.
505                event.ignore()
506                return
507
508        QPlainTextEdit.keyPressEvent(self, event)
509
510        if not len(event.text()) or not is_printable(unicode(event.text())[0]):
511            return
512
513        text = unicode(self.toPlainText())
514        cursor = self.textCursor()
515        pos = cursor.position()
516
517        if pos == len(text) or not(text[pos].strip()):
518            # cursor is at end of text or whitespace
519            # find the beginning of the current word
520            whitespace = " \t\n\r\f\v"
521            start = max([text.rfind(c, 0, pos) for c in whitespace]) + 1
522
523            prefix = text[start:pos]
524
525            if prefix:
526                if self._completer.completionPrefix() != prefix:
527                    self._completer.setCompletionPrefix(text[start:pos])
528
529                rect = self.cursorRect()
530                popup = self._completer.popup()
531                if popup.isVisible():
532                    rect.setWidth(popup.width())
533                else:
534                    rect.setWidth(popup.sizeHintForColumn(0) +
535                                  popup.verticalScrollBar().sizeHint().width())
536
537                # Popup the completer list
538                self._completer.complete(rect)
539
540                # Inline completion of a common prefix
541                inline = self._commonCompletionPrefix()
542                inline = inline[len(prefix):]
543
544                self._completionState = \
545                    _CompletionState(start, pos, pos + len(inline))
546
547                cursor.insertText(inline)
548                cursor.setPosition(pos, QTextCursor.KeepAnchor)
549                self.setTextCursor(cursor)
550
551            elif self._completer.popup().isVisible():
552                self._stopCompletion()
553
554    def _cursorPositionChanged(self):
555        cursor = self.textCursor()
556        pos = cursor.position()
557        start, _, _ = self._completionState
558
559        if start == -1:
560            # completion not in progress
561            return
562
563        if pos <= start:
564            # cursor moved before the start of the prefix
565            self._stopCompletion()
566            return
567
568        text = unicode(self.toPlainText())
569        # Find the end of the word started by completion prefix
570        word_end = len(text)
571        for i in range(start, len(text)):
572            if text[i] in " \t\n\r\f\v":
573                word_end = i
574                break
575
576        if pos > word_end:
577            # cursor moved past the word boundary
578            self._stopCompletion()
579
580        # TODO: Update the prefix when moving the cursor
581        # inside the word
582
583    def _insertCompletion(self, item):
584        if isinstance(item, list):
585            completion = " ".join(item)
586        else:
587            completion = unicode(item)
588
589        start, _, end = self._completionState
590
591        self._stopCompletion()
592
593        cursor = self.textCursor()
594        # Replace the prefix+inline with the full completion
595        # (correcting for the case-insensitive search).
596        cursor.setPosition(min(end, self.document().characterCount()))
597        cursor.setPosition(start, QTextCursor.KeepAnchor)
598
599        cursor.insertText(completion + " ")
600
601    def _commonCompletionPrefix(self):
602        """
603        Return the common prefix of items in the current completion model.
604        """
605        model = self._completer.completionModel()
606        column = self._completer.completionColumn()
607        role = self._completer.completionRole()
608        items = [toString(model.index(i, column).data(role))
609                 for i in range(model.rowCount())]
610
611        if not items:
612            return ""
613
614        first = min(items)
615        last = max(items)
616        for i, c in enumerate(first):
617            if c != last[i]:
618                return first[:i]
619
620        return first
621
622    def _stopCompletion(self):
623        self._completionState = _CompletionState(-1, -1, -1)
624        if self._completer.popup().isVisible():
625            self._completer.popup().hide()
626
627    def _textChanged(self):
628        items = self._getItems()
629        if self._items != items:
630            self._items = items
631            self.itemsChanged.emit(items)
632
633    def _getItems(self):
634        """
635        Return the current items (a list of strings).
636
637        .. note:: The inline completion text is not included.
638
639        """
640        text = unicode(self.toPlainText())
641        if self._completionState[0] != -1:
642            # Remove the inline completion text
643            _, pos, end = self._completionState
644            text = text[:pos] + text[end:]
645        return [item for item in text.split() if item.strip()]
646
647
648class NameHighlight(QSyntaxHighlighter):
649    def __init__(self, parent=None, **kwargs):
650        super(NameHighlight, self).__init__(parent, **kwargs)
651
652        self._names = set()
653
654        self._format = QTextCharFormat()
655        self._format.setForeground(Qt.blue)
656
657        self._unrecognized_format = QTextCharFormat()
658#         self._unrecognized_format.setFontStrikeOut(True)
659
660    def setNames(self, names):
661        self._names = set(names)
662        self.rehighlight()
663
664    def names(self):
665        return set(self._names)
666
667    def highlightBlock(self, text):
668        text = unicode(text)
669        pattern = re.compile(r"\S+")
670        for match in pattern.finditer(text):
671            name = text[match.start(): match.end()]
672            match_len = match.end() - match.start()
673
674            if not name.strip():
675                continue
676
677            if name in self._names:
678                format = self._format
679            else:
680                format = self._unrecognized_format
681
682            self.setFormat(match.start(), match_len, format)
683
684
685@contextmanager
686def signals_blocked(obj):
687    blocked = obj.signalsBlocked()
688    obj.blockSignals(True)
689    try:
690        yield
691    finally:
692        obj.blockSignals(blocked)
693
694
695class ListCompleter(QCompleter):
696    """
697    A completer supporting selection of multiple list items.
698    """
699    activated = Signal(list)
700
701    def __init__(self, *args, **kwargs):
702        QCompleter.__init__(self, *args, **kwargs)
703
704        popup = QListView()
705        popup.setEditTriggers(QListView.NoEditTriggers)
706        popup.setSelectionMode(QListView.ExtendedSelection)
707
708        self.setPopup(popup)
709
710    def setPopup(self, popup):
711        QCompleter.setPopup(self, popup)
712
713        popup.viewport().installEventFilter(self)
714        popup.doubleClicked.connect(self._complete)
715
716    def eventFilter(self, receiver, event):
717        if event.type() == QEvent.KeyPress and receiver is self.popup():
718            if event.key() in [Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab]:
719                self._complete()
720                return True
721
722        elif event.type() == QEvent.MouseButtonRelease and \
723                receiver is self.popup().viewport():
724            # Process the event without emitting 'clicked', ... signal to
725            # override the default QCompleter behavior
726            with signals_blocked(self.popup()):
727                QApplication.sendEvent(self.popup(), event)
728                return True
729
730        return QCompleter.eventFilter(self, receiver, event)
731
732    def _complete(self):
733        selection = self.popup().selectionModel().selection()
734        indexes = selection.indexes()
735
736        items = [toString(index.data(self.completionRole()))
737                 for index in indexes]
738
739        if self.popup().isVisible():
740            self.popup().hide()
741
742        if items:
743            self.activated.emit(items)
744
745
746# All control character categories.
747_control = set(["Cc", "Cf", "Cs", "Co", "Cn"])
748
749
750def is_printable(unichar):
751    """
752    Return True if the unicode character `unichar` is a printable character.
753    """
754    return unicodedata.category(unichar) not in _control
755
756
757def test():
758    app = QApplication([])
759    w = OWSelectGenes()
760    data = Orange.data.Table("brown-selected")
761    w.setData(data)
762    w.show()
763    app.exec_()
764    w.saveSettings()
765    w.deleteLater()
766    del w
767    app.processEvents()
768
769if __name__ == "__main__":
770    test()
Note: See TracBrowser for help on using the repository browser.