source: orange-bioinformatics/_bioinformatics/widgets/OWSelectGenes.py @ 1853:124130167b7d

Revision 1853:124130167b7d, 15.5 KB checked in by Ales Erjavec <ales.erjavec@…>, 7 months ago (diff)

Added inline completion of the common prefix.

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
29class OWSelectGenes(OWWidget):
30
31    contextHandlers = {
32        "": DomainContextHandler(
33            "", ["geneIndex", "selection"]
34        )
35    }
36
37    settingsList = ["autoCommit", "preserveOrder"]
38
39    def __init__(self, parent=None, signalManager=None, title=NAME):
40        OWWidget.__init__(self, parent, signalManager, title,
41                          wantMainArea=False)
42
43        self.selection = []
44        self.geneIndex = None
45        self.autoCommit = False
46        self.preserveOrder = True
47
48        self.loadSettings()
49
50        # Input variables that could contain names
51        self.variables = VariableListModel()
52        # All gene names from the input (in self.geneIndex column)
53        self.geneNames = []
54        # Output changed flag
55        self._changedFlag = False
56        self.data = None
57
58        box = OWGUI.widgetBox(self.controlArea, "Gene Attribute")
59        self.attrsCombo = OWGUI.comboBox(
60            box, self, "geneIndex",
61            callback=self._onGeneIndexChanged,
62            tooltip="Column with gene names"
63        )
64        self.attrsCombo.setModel(self.variables)
65
66        box = OWGUI.widgetBox(self.controlArea, "Gene Selection")
67        self.entryField = ListTextEdit(box)
68        self.entryField.setTabChangesFocus(True)
69        self.entryField.setToolTip("Enter selected gene names")
70        self.entryField.itemsChanged.connect(self._itemsChanged)
71
72        box.layout().addWidget(self.entryField)
73
74        completer = ListCompleter(self)
75        completer.setCompletionMode(QCompleter.PopupCompletion)
76        completer.setCaseSensitivity(Qt.CaseInsensitive)
77        completer.setMaxVisibleItems(10)
78        completer.popup().setAlternatingRowColors(True)
79        completer.setModel(QStringListModel([], self))
80
81        self.entryField.setCompleter(completer)
82
83        self.hightlighter = NameHighlight(self.entryField.document())
84
85        box = OWGUI.widgetBox(self.controlArea, "Output")
86        OWGUI.checkBox(box, self, "preserveOrder", "Preserve input order",
87                       tooltip="Preserve the order of the input data "
88                               "instances.",
89                       callback=self.invalidateOutput)
90        cb = OWGUI.checkBox(box, self, "autoCommit", "Auto commit")
91        button = OWGUI.button(box, self, "Commit", callback=self.commit)
92
93        OWGUI.setStopper(self, button, cb, "_changedFlag", self.commit)
94
95    def set_data(self, data):
96        """
97        Set the input data.
98        """
99        self.closeContext("")
100        self.warning()
101        self.data = data
102        if data is not None:
103            attrs = gene_candidates(data)
104            self.variables[:] = attrs
105            self.attrsCombo.setCurrentIndex(0)
106            self.geneIndex = 0
107            self.selection = []
108        else:
109            self.variables[:] = []
110            self.geneIndex = -1
111            self.warning(0, "No suitable columns for gene names.")
112
113        self._changedFlag = True
114        self._updateCompletionModel()
115
116        self.openContext("", data)
117
118        self.entryField.setPlainText(" ".join(self.selection))
119
120        self.commit()
121
122    @property
123    def geneVar(self):
124        if self.data is not None and self.geneIndex >= 0:
125            return self.variables[self.geneIndex]
126        else:
127            return None
128
129    def invalidateOutput(self):
130        if self.autoCommit:
131            self.commit()
132        else:
133            self._changedFlag = True
134
135    def commit(self):
136        gene = self.geneVar
137
138        if gene is not None:
139            if self.preserveOrder:
140                selection = set(self.selection)
141                sel = [inst for inst in self.data
142                       if str(inst[gene]) in selection]
143            else:
144                by_genes = defaultdict(list)
145                for inst in self.data:
146                    by_genes[str(inst[gene])].append(inst)
147
148                sel = []
149                for name in self.selection:
150                    sel.extend(by_genes.get(name, []))
151
152            if sel:
153                data = Orange.data.Table(self.data.domain, sel)
154            else:
155                data = Orange.data.Table(self.data.domain)
156
157        else:
158            data = None
159
160        self.send("Selected Data", data)
161        self._changedFlag = False
162
163    def _updateCompletionModel(self):
164        var = self.geneVar
165        if var is not None:
166            names = [str(inst[var]) for inst in self.data
167                     if not inst[var].isSpecial()]
168        else:
169            names = []
170
171        self.geneNames = names
172        self.entryField.completer().model().setStringList(sorted(set(names)))
173        self.hightlighter.setNames(names)
174
175    def _onGeneIndexChanged(self):
176        self._updateCompletionModel()
177        self.invalidateOutput()
178
179    def _itemsChanged(self, names):
180        selection = set(names).intersection(self.geneNames)
181        curr_selection = set(self.selection).intersection(self.geneNames)
182
183        if selection != curr_selection:
184            self.selection = names
185            self.invalidateOutput()
186
187            names = set(self.geneNames) - set(names)
188            self.entryField.completer().model().setStringList(sorted(names))
189
190
191def is_string(feature):
192    return isinstance(feature, Orange.feature.String)
193
194
195def domain_variables(domain):
196    vars = (domain.features +
197            domain.class_vars +
198            domain.getmetas().values())
199    return vars
200
201
202def gene_candidates(data):
203    vars = domain_variables(data.domain)
204    vars = filter(is_string, vars)
205    return vars
206
207
208_CompletionState = namedtuple(
209    "_CompletionState",
210    ["start",  # completion prefix start position
211     "pos",  # cursor position
212     "anchor"]  # anchor position (inline completion end)
213)
214
215
216class ListTextEdit(QPlainTextEdit):
217    itemsChanged = Signal(list)
218
219    def __init__(self, parent=None, **kwargs):
220        QPlainTextEdit.__init__(self, parent, **kwargs)
221
222        self._items = None
223        self._completer = None
224        self._completionState = _CompletionState(-1, -1, -1)
225
226        self.cursorPositionChanged.connect(self._cursorPositionChanged)
227        self.textChanged.connect(self._textChanged)
228
229    def setCompleter(self, completer):
230        """
231        Set a completer for list items.
232        """
233        if self._completer is not None:
234            self._completer.setWidget(None)
235            self._completer.activated.disconnect(self._insertCompletion)
236
237        self._completer = completer
238
239        if self._completer:
240            self._completer.setWidget(self)
241            self._completer.activated.connect(self._insertCompletion)
242
243    def completer(self):
244        """
245        Return the completer.
246        """
247        return self._completer
248
249    def setItems(self, items):
250        text = " ".join(items)
251        self.setPlainText(text)
252
253    def items(self):
254        if self._items is None:
255            self._items = self._getItems()
256        return self._items
257
258    def keyPressEvent(self, event):
259        # TODO: in Qt 4.8 QPlainTextEdit uses inputMethodEvent for
260        # non-ascii input
261
262        if self._completer.popup().isVisible():
263            if event.key() in [Qt.Key_Enter, Qt.Key_Return, Qt.Key_Escape,
264                               Qt.Key_Tab, Qt.Key_Backtab]:
265                # These need to propagate to the completer.
266                event.ignore()
267                return
268
269        QPlainTextEdit.keyPressEvent(self, event)
270
271        if not len(event.text()) or not is_printable(unicode(event.text())[0]):
272            return
273
274        text = unicode(self.toPlainText())
275        cursor = self.textCursor()
276        pos = cursor.position()
277
278        if pos == len(text) or not(text[pos].strip()):
279            # cursor is at end of text or whitespace
280            # find the beginning of the current word
281            whitespace = " \t\n\r\f\v"
282            start = max([text.rfind(c, 0, pos) for c in whitespace]) + 1
283
284            prefix = text[start:pos]
285
286            if prefix:
287                if self._completer.completionPrefix() != prefix:
288                    self._completer.setCompletionPrefix(text[start:pos])
289
290                rect = self.cursorRect()
291                popup = self._completer.popup()
292                if popup.isVisible():
293                    rect.setWidth(popup.width())
294                else:
295                    rect.setWidth(popup.sizeHintForColumn(0) +
296                                  popup.verticalScrollBar().sizeHint().width())
297
298                # Popup the completer list
299                self._completer.complete(rect)
300
301                # Inline completion of a common prefix
302                inline = self._commonCompletionPrefix()
303                inline = inline[len(prefix):]
304
305                self._completionState = \
306                    _CompletionState(start, pos, pos + len(inline))
307
308                cursor.insertText(inline)
309                cursor.setPosition(pos, QTextCursor.KeepAnchor)
310                self.setTextCursor(cursor)
311
312            elif self._completer.popup().isVisible():
313                self._stopCompletion()
314
315    def _cursorPositionChanged(self):
316        cursor = self.textCursor()
317        pos = cursor.position()
318        start, _, _ = self._completionState
319
320        if start == -1:
321            # completion not in progress
322            return
323
324        if pos <= start:
325            # cursor moved before the start of the prefix
326            self._stopCompletion()
327            return
328
329        text = unicode(self.toPlainText())
330        # Find the end of the word started by completion prefix
331        word_end = len(text)
332        for i in range(start, len(text)):
333            if text[i] in " \t\n\r\f\v":
334                word_end = i
335                break
336
337        if pos > word_end:
338            # cursor moved past the word boundary
339            self._stopCompletion()
340
341        # TODO: Update the prefix when moving the cursor
342        # inside the word
343
344    def _insertCompletion(self, item):
345        if isinstance(item, list):
346            completion = " ".join(item)
347        else:
348            completion = unicode(item)
349
350        start, _, end = self._completionState
351
352        self._stopCompletion()
353
354        cursor = self.textCursor()
355        # Replace the prefix+inline with the full completion
356        # (correcting for the case-insensitive search).
357        cursor.setPosition(min(end, self.document().characterCount()))
358        cursor.setPosition(start, QTextCursor.KeepAnchor)
359
360        cursor.insertText(completion + " ")
361
362    def _commonCompletionPrefix(self):
363        model = self._completer.completionModel()
364        column = self._completer.completionColumn()
365        role = self._completer.completionRole()
366        items = [toString(model.index(i, column).data(role))
367                 for i in range(model.rowCount())]
368
369        if not items:
370            return ""
371
372        first = min(items)
373        last = max(items)
374        for i, c in enumerate(first):
375            if c != last[i]:
376                return first[:i]
377
378        return first
379
380    def _stopCompletion(self):
381        self._completionState = _CompletionState(-1, -1, -1)
382        if self._completer.popup().isVisible():
383            self._completer.popup().hide()
384
385    def _textChanged(self):
386        items = self._getItems()
387        if self._items != items:
388            self._items = items
389            self.itemsChanged.emit(items)
390
391    def _getItems(self):
392        text = unicode(self.toPlainText())
393        if self._completionState[0] != -1:
394            # Remove the inline completion text from the text
395            _, pos, end = self._completionState
396            text = text[:pos] + text[end:]
397        return [item for item in text.split() if item.strip()]
398
399
400class NameHighlight(QSyntaxHighlighter):
401    def __init__(self, parent=None, **kwargs):
402        super(NameHighlight, self).__init__(parent, **kwargs)
403
404        self._names = set()
405
406        self._format = QTextCharFormat()
407        self._format.setForeground(Qt.blue)
408
409        self._unrecognized_format = QTextCharFormat()
410#         self._unrecognized_format.setFontStrikeOut(True)
411
412    def setNames(self, names):
413        self._names = set(names)
414        self.rehighlight()
415
416    def names(self):
417        return set(self._names)
418
419    def highlightBlock(self, text):
420        text = unicode(text)
421        pattern = re.compile(r"\S+")
422        for match in pattern.finditer(text):
423            name = text[match.start(): match.end()]
424            match_len = match.end() - match.start()
425
426            if not name.strip():
427                continue
428
429            if name in self._names:
430                format = self._format
431            else:
432                format = self._unrecognized_format
433
434            self.setFormat(match.start(), match_len, format)
435
436
437def toString(variant):
438    if isinstance(variant, QVariant):
439        return unicode(variant.toString())
440    else:
441        return unicode(variant)
442
443
444@contextmanager
445def signals_blocked(obj):
446    blocked = obj.signalsBlocked()
447    obj.blockSignals(True)
448    try:
449        yield
450    finally:
451        obj.blockSignals(blocked)
452
453
454class ListCompleter(QCompleter):
455    activated = Signal(list)
456
457    def __init__(self, *args, **kwargs):
458        QCompleter.__init__(self, *args, **kwargs)
459
460        popup = QListView()
461        popup.setEditTriggers(QListView.NoEditTriggers)
462        popup.setSelectionMode(QListView.ExtendedSelection)
463
464        self.setPopup(popup)
465
466    def setPopup(self, popup):
467        QCompleter.setPopup(self, popup)
468
469        popup.viewport().installEventFilter(self)
470        popup.doubleClicked.connect(self._complete)
471
472    def eventFilter(self, receiver, event):
473        if event.type() == QEvent.KeyPress and receiver is self.popup():
474            if event.key() in [Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab]:
475                self._complete()
476                return True
477
478        elif event.type() == QEvent.MouseButtonRelease and \
479                receiver is self.popup().viewport():
480            # Process the event without emitting 'clicked', ... signal to
481            # override the default QCompleter behavior
482            with signals_blocked(self.popup()):
483                QApplication.sendEvent(self.popup(), event)
484                return True
485
486        return QCompleter.eventFilter(self, receiver, event)
487
488    def _complete(self):
489        selection = self.popup().selectionModel().selection()
490        indexes = selection.indexes()
491
492        items = [toString(index.data(self.completionRole()))
493                 for index in indexes]
494
495        if self.popup().isVisible():
496            self.popup().hide()
497
498        if items:
499            self.activated.emit(items)
500
501
502# All control character categories.
503_control = set(["Cc", "Cf", "Cs", "Co", "Cn"])
504
505
506def is_printable(unichar):
507    """
508    Return True if the unicode character `unichar` is a printable character.
509    """
510    return unicodedata.category(unichar) not in _control
511
512
513def test():
514    app = QApplication([])
515    w = OWSelectGenes()
516    data = Orange.data.Table("brown-selected")
517    w.set_data(data)
518    w.show()
519    app.exec_()
520    w.deleteLater()
521    del w
522    app.processEvents()
523
524if __name__ == "__main__":
525    test()
Note: See TracBrowser for help on using the repository browser.