1""" OWMarkerGenes """
2from typing import List, Tuple, Iterable, Optional
3from functools import partial
4
5import numpy as np
6import requests
7from orangewidget.settings import Setting, ContextHandler
8
9from AnyQt import QtGui, QtCore
10from AnyQt.QtCore import Qt, QSize, QMimeData, QModelIndex, QAbstractItemModel, QSortFilterProxyModel, pyqtSignal
11from AnyQt.QtWidgets import QLayout, QWidget, QLineEdit, QTreeView, QGridLayout, QTextBrowser, QAbstractItemView
12
13from Orange.data import Table, RowInstance
14from Orange.widgets import gui, widget, settings
15
16from orangecontrib.bioinformatics.utils import serverfiles
17from orangecontrib.bioinformatics.widgets.utils.data import TAX_ID, GENE_ID_COLUMN, GENE_AS_ATTRIBUTE_NAME
18from orangecontrib.bioinformatics.widgets.utils.settings import MarkerGroupContextHandler
19
20SERVER_FILES_DOMAIN = 'marker_genes'
21GROUP_BY_ITEMS = ["Cell Type", "Function"]
22FILTER_COLUMNS_DEFAULT = ["Name", "Entrez ID"]
23NUM_LINES_TEXT = 5
24MAP_GROUP_TO_TAX_ID = {'Human': '9606', 'Mouse': '10090'}
25
26
27class TreeItem(object):
28    def __init__(
29        self, name: str, is_type_item: bool, data_row: Optional[RowInstance], parent: Optional["TreeItem"] = None
30    ):
31        self.name: str = name
32        self.parentItem: Optional["TreeItem"] = None
33        self.childItems: List = []
34        self.change_parent(parent)
35        self.isTypeItem: bool = is_type_item
36
37        self.data_row: Optional[RowInstance] = data_row
38        # holds the information whether element is expanded in the tree
39        self.expanded = False
40
41    def change_parent(self, parent: Optional["TreeItem"] = None) -> None:
42        """
43        Function replaces the item parent.
44
45        Parameters
46        ----------
47        parent
48            Parent which is set as an items parent
49        """
50        self.parentItem = parent
51        if parent is not None:
52            parent.append_child(self)
53
54    @staticmethod
55    def change_parents(items: Iterable["TreeItem"], parent: "TreeItem") -> None:
56        """
57        This function assigns all items a parent
58
59        Parameters
60        ----------
61        items
62            Items that will get parent assigned
63        parent
64            Parent that will be assigned
65        """
66        for it in items:
67            it.change_parent(parent)
68
69    def append_child(self, item: "TreeItem") -> None:
70        """
71        This function appends child to self. This function just append a child
72        and do not set parent of child item (it is set by function calling this function.
73
74        Parameters
75        ----------
76        item
77            Child item.
78        """
79        self.childItems.append(item)
80
81    def child(self, row: int) -> "TreeItem":
82        """
83        Returns row-th child the item.
84
85        Parameters
86        ----------
87        row
88            Index of the child
89
90        Returns
91        -------
92        Child item
93        """
94        return self.childItems[row]
95
96    def remove_children(self, position: int, count: int) -> bool:
97        """
98        This function removes children form position to position + count
99
100        Parameters
101        ----------
102        position
103            Position of the first removed child
104        count
105            Number of removed children
106        Returns
107        -------
108        Success of removing
109        """
110        # we could check for every index separately but in case when all items cannot be removed there is something
111        # wrong with the cal - just refuse to do the removal
112        if position < 0 or position + count > len(self.childItems):
113            return False
114        # remove from the back otherwise you get index out of range when removing more than one
115        for i in range(position + count - 1, position - 1, -1):
116            self.childItems.pop(i)
117        return True
118
119    def children_count(self) -> int:
120        """
121        Function return number fo children of an item.
122
123        Returns
124        -------
125        Number of children.
126        """
127        return len(self.childItems)
128
129    def row(self) -> int:
130        """
131        Function returns the index of the item in a parents list. If it does not have parent returns 0.
132
133        Returns
134        -------
135        The index of an item.
136        """
137        if self.parentItem:
138            return self.parentItem.childItems.index(self)
139        return 0
140
141    def child_from_name_index(self, name: str) -> Tuple[Optional[int], Optional["TreeItem"]]:
142        """
143        This function returns the index (in the array of children) and item with the name specified.
144
145        Parameters
146        ----------
147        name
148            The name of requested item.
149
150        Returns
151        -------
152        The index and the searched item. If it does not exist return None.
153        """
154        for i, c in enumerate(self.childItems):
155            if c.name == name:
156                return i, c
157        return None, None
158
159    def contains_text(self, text: str, filter_columns: List[str]) -> bool:
160        """
161        Function indicates whether the text is present in any of columns in FILTER_COLUMNS.
162
163        Parameters
164        ----------
165        text
166            Tested text.
167        filter_columns
168            Columns used in filtering
169
170        Returns
171        -------
172        Boolean that indicates that text is present in the item.
173        """
174        if self.data_row is not None:
175            return any(text.lower() in str(self.data_row[col]).lower() for col in filter_columns)
176        else:
177            return False
178
179    def get_data_rows(self) -> List[RowInstance]:
180        """
181        This function returns a list of rows present in self and any children node.
182
183        Returns
184        -------
185        Rows with data items.
186        """
187        rows = [self.data_row] if self.data_row is not None else []
188        for c in self.childItems:
189            rows += c.get_data_rows()
190        return rows
191
192    def count_marker_genes(self) -> int:
193        """
194        This function counts number of marker genes in its tree
195
196        Returns
197        -------
198        int
199            Number of marker genes in the tree including itself.
200        """
201        return sum(c.count_marker_genes() for c in self.childItems) + (0 if self.data_row is None else 1)
202
203
204class TreeModel(QAbstractItemModel):
205
206    MIME_TYPE = "application/x-Orange-TreeModelData"
207    # it was slow to relay on the qt model signals since they are emitted every time endInsertRows is called
208    # this one will be emitted just a the end
209    data_added = pyqtSignal()
210    data_removed = pyqtSignal()
211    expand_item = pyqtSignal(QModelIndex, bool)
212
213    def __init__(self, data: Table, parent_column: str, parent: QModelIndex = None) -> None:
214        super().__init__(parent)
215
216        self.rootItem = TreeItem("Genes", False, None)
217        self.setup_model_data(data, self.rootItem, parent_column)
218        self.filter_columns = FILTER_COLUMNS_DEFAULT + [parent_column]
219
220    def flags(self, index: QModelIndex) -> Qt.ItemFlag:
221        if not index.isValid():
222            return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDropEnabled
223        return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsDragEnabled
224
225    def supportedDropActions(self) -> Qt.DropAction:  # noqa: N802
226        return Qt.MoveAction
227
228    def supportedDragActions(self) -> Qt.DropAction:  # noqa: N802
229        return Qt.MoveAction
230
231    def columnCount(self, parent: QModelIndex = None) -> int:  # noqa: N802
232        """
233        Our TreeView has only one column
234        """
235        return 1
236
237    def rowCount(self, parent: QModelIndex = None) -> int:  # noqa: N802
238        """
239        Function returns the number children of an item.
240        """
241        if not parent or not parent.isValid():
242            parent_item = self.rootItem
243        else:
244            parent_item = self.node_from_index(parent)
245
246        count = parent_item.children_count()
247        return count
248
249    def data(self, index: QModelIndex, role=None) -> Optional[str]:
250        """
251        Function returns the index's data that are shown in the view.
252        """
253        if not index.isValid() or role != Qt.DisplayRole:
254            return None
255        item = self.node_from_index(index)
256        rd = item.name
257        return rd
258
259    def headerData(self, section, orientation, role=None):  # noqa: N802
260        """
261        Return header data (they are no used so function is just here to make Qt happy).
262        """
263        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
264            return self.rootItem.name
265        return None
266
267    def index(self, row: int, column: int, parent: QModelIndex = None) -> QModelIndex:
268        """
269        Function create QModelIndex from the parent item and row and column index.
270
271        Parameters
272        ----------
273        row
274            Row of the child
275        column
276            Column of th child - not used since we have only one column.
277        parent
278            Parent of the child we are making index of.
279
280        Returns
281        -------
282        Index of the child at row-th row of the parent.
283        """
284        if not self.hasIndex(row, column, parent):
285            return QModelIndex()
286
287        if not parent.isValid():
288            parent_item = self.rootItem
289        else:
290            parent_item = self.node_from_index(parent)
291
292        child_item = parent_item.child(row)
293        if child_item:
294            return self.createIndex(row, column, child_item)
295        else:
296            return QModelIndex()
297
298    def parent(self, index: QModelIndex) -> QModelIndex:
299        """
300        Function returns parent of the item in index.
301        """
302        if not index.isValid():
303            return QModelIndex()
304
305        child_item = self.node_from_index(index)
306        parent_item = child_item.parentItem if child_item else None
307
308        if not parent_item or parent_item == self.rootItem:
309            return QModelIndex()
310
311        return self.createIndex(parent_item.row(), 0, parent_item)
312
313    def removeRows(  # noqa: N802
314        self, position: int, rows: int, parent: QModelIndex = QModelIndex(), emit_data_removed: bool = True
315    ) -> bool:
316        """
317        This function implements removing rows form position to position + rows form the model. It also removes
318        a parent when it becomes empty. If remove is called over the parent it remove children together with the
319        parent.
320
321        Parameters
322        ----------
323        emit_data_removed
324            It indicates whether we emit dataRemoved signal. It makes possible that it is emitted when function called
325            by Qt and not when called by removeNodeList. When qt call this function indices are optimized to be in
326            range and this function is called just once for consecutive data. It is not true for data from
327            removeNodeList. In this case we call removeRows for each removed item and it can become slow when many
328            items are removed.
329        """
330        node = self.node_from_index(parent)
331        if not node:
332            return False
333        if not (position == 0 and node.children_count() == rows) or not node.isTypeItem:
334            # when not all children are removed or when type item is removed - when removed node is a type item its
335            # parent is not
336            self.beginRemoveRows(QModelIndex() if node is self.rootItem else parent, position, position + rows - 1)
337            success = node.remove_children(position, rows)
338        else:
339            # when node is a children of a type item and all children of node needs to be removed, remove node itself
340            self.beginRemoveRows(QModelIndex(), node.row(), node.row())
341            success = self.rootItem.remove_children(node.row(), 1)
342        self.endRemoveRows()
343        if emit_data_removed:
344            self.data_removed.emit()
345        return success
346
347    def mimeTypes(self) -> List[str]:  # noqa: N802
348        return [self.MIME_TYPE]
349
350    def mimeData(self, index_list: List[QModelIndex]) -> QMimeData:  # noqa: N802
351        """
352        This function packs data in QMimeData in order to move them to other view with drag and drop functionality.
353        """
354        items = [self.node_from_index(index) for index in index_list]
355        mime = QMimeData()
356        # the encoded 'data' is empty, variables are passed by properties
357        mime.setData(self.MIME_TYPE, b'')
358        mime.setProperty("_items", items)
359        mime.setProperty("_source_id", id(self))
360        return mime
361
362    def dropMimeData(  # noqa: N802
363        self, mime: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex = QModelIndex
364    ) -> bool:
365        """
366        This function unpacks QMimeData and insert them to the view where they are dropped.
367        Inserting is a bit complicated since sometimes we need to create a parent when it is not in the view yet:
368        - when child (marker gene) is dropped we insert it under the current parent (group) if it exist othervise
369          we crate a new parent.
370        - when group item is inserted it merge it with current same parent item if exist or insert it if it does
371          not exists
372        """
373        if action == Qt.IgnoreAction:
374            return True
375        if not mime.hasFormat(self.MIME_TYPE):
376            return False
377        items = mime.property("_items")
378        if items is None:
379            return False
380        # when parent item in items remove its child since all group will be moved
381        items = [it for it in items if it.parentItem not in items]
382        self.insert_items(items)
383        self.data_added.emit()
384        return True
385
386    def insert_items(self, items: List[TreeItem]) -> None:
387        """
388        This function goes through all items and insert them in the view.
389        Parameters
390        ----------
391        items
392            List of items that will be inserted.
393        """
394        for item in items:
395            if item.isTypeItem:
396                # inserting group item
397                self.insert_group(item)
398            else:
399                # inserting children (genes)
400                self.insert_child_item(item)
401
402    def insert_group(self, item: TreeItem) -> None:
403        """
404        This function makes insert of the item in the model in case that it is a group item.
405
406        Parameters
407        ----------
408        item
409            Item that is inserted - group item (not gene).
410        """
411        expand = None
412
413        item_curr_idx, curr_item = self.rootItem.child_from_name_index(item.name)
414        if item_curr_idx is None:
415            # it is not in the view yet - just insert it
416            self.beginInsertRows(QModelIndex(), self.rootItem.children_count(), self.rootItem.children_count())
417            item.change_parent(self.rootItem)
418            expand = item.expanded
419        else:
420            # it is in the view already - insert only children
421            tr_idx = self.createIndex(item_curr_idx, 0, curr_item)
422            self.beginInsertRows(tr_idx, self.rowCount(tr_idx), self.rowCount(tr_idx) + len(item.childItems) - 1)
423            TreeItem.change_parents(item.childItems, curr_item)
424        self.endInsertRows()
425
426        # emit the signal if item must be expanded
427        if expand is not None:
428            self.expand_item.emit(self.index_from_node(item), expand)
429
430    def insert_child_item(self, item: TreeItem) -> None:
431        """
432        This function makes insert of the item in the model in case that it is a child (gene) item.
433
434        Parameters
435        ----------
436        item
437            Item that is inserted - child item (gene).
438        """
439        expand_parent = None
440        parent_curr_idx, curr_parent = self.rootItem.child_from_name_index(item.parentItem.name)
441        if parent_curr_idx is None:
442            # parent (group) do not exists yet - crate it
443            self.beginInsertRows(QModelIndex(), self.rootItem.children_count(), self.rootItem.children_count())
444            curr_parent = TreeItem(item.parentItem.name, True, None, self.rootItem)
445            # when creating new parent because child is inserted always expend since it is expanded in the other view
446            expand_parent = True
447        else:
448            # parent (group) exists - insert into it
449            tr_idx = self.createIndex(parent_curr_idx, 0, curr_parent)
450            self.beginInsertRows(tr_idx, self.rowCount(tr_idx), self.rowCount(tr_idx))
451        item.change_parent(curr_parent)
452        self.endInsertRows()
453
454        # emit the signal if parent must be expanded
455        if expand_parent is not None:
456            self.expand_item.emit(self.index_from_node(curr_parent), expand_parent)
457
458    def canDropMimeData(  # noqa: N802
459        self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex
460    ) -> bool:
461        """
462        This function enable or disable drop action to the view. With current implementation we disable drop action to
463        the same view.
464        """
465        if data.property("_source_id") == id(self):
466            return False
467        else:
468            return True
469
470    def remove_node_list(self, indices: List[QModelIndex]) -> None:
471        """
472        Sometimes we need to remove items provided as a list of QModelIndex. Since it is not possible directly with
473        removeRows it is implemented with this function.
474
475        Parameters
476        ----------
477        indices
478            List of nodes as QModelIndex
479        """
480        for idx in indices:
481            node = self.node_from_index(idx)
482            if node in node.parentItem.childItems:  # it is possible that item already removed since no children left
483                parent_idx = self.index_from_node(node.parentItem)
484                self.removeRows(node.row(), 1, parent_idx, emit_data_removed=False)
485        self.data_removed.emit()
486
487    def node_from_index(self, index: QModelIndex) -> TreeItem:  # noqa: N802
488        """
489        Function returns a TreeItem saved in the index.
490
491        Parameters
492        ----------
493        index
494            Index of the TreeItem
495
496        Returns
497        -------
498            Tree item from the index.
499        """
500        if index.isValid():
501            return index.internalPointer()
502        else:
503            return self.rootItem
504
505    def index_from_node(self, node: TreeItem) -> QModelIndex:  # noqa: N802
506        """
507        This function is similar to the index function. We use it in case when we create index but not base on the
508        parent index. We could call index but it is faster to create it directly.
509
510        Parameters
511        ----------
512        node
513            TreeItem for which we crate index.
514
515        Returns
516        -------
517        QModelIndex for TreeItem
518        """
519        return self.createIndex(node.row(), 0, node)
520
521    def index_from_name(self, name: str, parent: TreeItem) -> Optional[QModelIndex]:  # noqa: N802
522        """
523        Function create index from tree item name.
524
525        Parameters
526        ----------
527        name
528            Name of the item
529        parent
530            Parent TreeItem
531
532        Returns
533        -------
534        QModelIndex for an item
535        """
536        i, node = parent.child_from_name_index(name)
537        if i is None:
538            return None
539        return self.createIndex(i, 0, node)
540
541    def setup_model_data(self, data_table: Table, parent: TreeItem, parent_column: str) -> None:  # noqa: N802
542        """
543        Function populates the view with the items from the data table. Items are inserted as a tree with the depth 2
544        each row (gene from the table) get inserted under the parent item (group) which is defined with the
545        paren_column parameter (e.g. marker gene).
546
547        Parameters
548        ----------
549        data_table
550            Data table with marker genes.
551        parent
552            Parent under which items are inserted (usually root item of the model).
553        parent_column
554            Column which is the group for items in the tree.
555        """
556        parents_dict = {}
557        names = data_table.get_column_view("Name")[0]
558        types = data_table.get_column_view(parent_column)[0]
559        for n, pt, row in zip(names, types, data_table):
560            if pt not in parents_dict:
561                parents_dict[pt] = TreeItem(pt, True, None, parent)
562
563            TreeItem(n, False, row, parents_dict[pt])
564
565    def set_expanded(self, index: QModelIndex, expanded: bool) -> None:
566        """
567        Sets the expanded flag of the item. This flag tell whether the item in the view is expanded.
568
569        Parameters
570        ----------
571        index
572            Index that is expanded/collapsed
573        expanded
574            If true expanded otherwise collapsed
575        """
576        node = self.node_from_index(index)
577        node.expanded = expanded
578
579    def __len__(self):
580        """
581        Len function in this case returns number of marker genes in the view.
582
583        Returns
584        -------
585        int
586            Number of marker genes in the view.
587        """
588        return self.rootItem.count_marker_genes()
589
590
591class FilterProxyModel(QSortFilterProxyModel):
592    """
593    FilterProxyModel is used to sort items in alphabetical order, and to make filter on the view of available genes.
594
595    Parameters
596    ----------
597    filter_line_edit
598        Line edit used to filter the view.
599    """
600
601    def __init__(self, filter_line_edit: QLineEdit) -> None:
602        super().__init__()
603        self.filter_line_edit = filter_line_edit
604        if filter_line_edit is not None:
605            filter_line_edit.textChanged.connect(self.setFilterFixedString)
606
607        # have marker genes in the alphabetical order
608        self.sort(0)
609
610    def filterAcceptsRow(self, p_int: int, index: QModelIndex) -> bool:  # noqa: N802
611        """
612        Indicates whether the item should be shown based on the the filter string.
613        """
614        if self.filter_line_edit is None:
615            return True
616        model = self.sourceModel()
617        idx = model.index(p_int, 0, index)
618        res = model.node_from_index(idx).contains_text(self.filter_line_edit.text(), self.sourceModel().filter_columns)
619
620        if model.hasChildren(idx):
621            num_items = model.rowCount(idx)
622            for i in range(num_items):
623                res = res or self.filterAcceptsRow(i, idx)
624        return res
625
626
627class TreeView(QTreeView):
628    """
629    QTreeView with some additional settings.
630    """
631
632    def __init__(self, *args, **kwargs) -> None:
633        super().__init__(*args, **kwargs)
634        self.setHeaderHidden(True)
635
636        # drag and drop enabled
637        self.setDragEnabled(True)
638        self.setAcceptDrops(True)
639        self.setDropIndicatorShown(True)
640        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
641        self.setUniformRowHeights(True)
642
643        # the view needs to be aware of the other view for the expanding purposes
644        self.otherView = None
645
646    def mousePressEvent(self, e: QtGui.QMouseEvent) -> None:  # noqa: N802
647        super().mousePressEvent(e)
648        self.handle_expand(e)
649
650    def mouseDoubleClickEvent(self, e: QtGui.QMouseEvent) -> None:  # noqa: N802
651        super().mouseDoubleClickEvent(e)
652        self.handle_expand(e)
653
654    def handle_expand(self, e: QtGui.QMouseEvent) -> None:
655        """
656        Check if click happened on the part with cause expanding the item in the view. If so also expand/collapse
657        the same item in the other view.
658        """
659        clicked_index = self.indexAt(e.pos())
660        if clicked_index.isValid():
661            # if click is left from text then it is expand event
662            vrect = self.visualRect(clicked_index)
663            item_identation = vrect.x() - self.visualRect(self.rootIndex()).x()
664            if e.pos().x() < item_identation:
665                self.expand_in_other_view(clicked_index, not self.isExpanded(clicked_index))
666
667    def expand_in_other_view(self, clicked_index: QModelIndex, is_expanded: bool) -> None:
668        """
669        Expand/collapse item which is same to the clicked_index in the other view. The other view is one of the left
670        if click happened on the right and vice versa.
671
672        Parameters
673        ----------
674        clicked_index
675            Index of the item that was expanded/collapsed
676        is_expanded
677            This flag is true if the item was expanded otherwise if false (collapsed).
678        """
679        source_model_this = self.model().sourceModel()
680        source_model_other = self.otherView.model().sourceModel()
681        clicked_model_index = self.model().mapToSource(clicked_index)
682
683        # register expanding change
684        source_model_this.set_expanded(clicked_model_index, is_expanded)
685
686        clicked_name = source_model_this.node_from_index(clicked_model_index).name
687
688        index_other = source_model_other.index_from_name(clicked_name, source_model_other.rootItem)
689        if index_other is not None:
690            # if same item (item with the same name) exists in the other view
691            self.otherView.setExpanded(index_other, is_expanded)
692
693    def setModel(self, model: QtCore.QAbstractItemModel) -> None:  # noqa: N802
694        """
695        Override setModel that we can connect expandItem signal. This signal is emitted when model
696        expand the item.
697        """
698        super().setModel(model)
699        self.model().sourceModel().expand_item.connect(self.setExpanded)
700
701    def setExpanded(self, index: QModelIndex, expand: bool) -> None:  # noqa: N802
702        """
703        Set item expanded/collapsed.
704
705        Parameters
706        ----------
707        index
708            Item's index
709        expand
710            This flag is true if the item was expanded otherwise if false (collapsed).
711        """
712        self.model().sourceModel().set_expanded(index, expand)
713        index = self.model().mapFromSource(index)
714        if expand:
715            self.expand(index)
716        else:
717            self.collapse(index)
718
719
720class OWMarkerGenes(widget.OWWidget):
721    name = "Marker Genes"
722    icon = 'icons/OWMarkerGenes.svg'
723    priority = 130
724
725    replaces = ['orangecontrib.single_cell.widgets.owmarkergenes.OWMarkerGenes']
726
727    class Warning(widget.OWWidget.Warning):
728        using_local_files = widget.Msg("Can't connect to serverfiles. Using cached files.")
729
730    class Outputs:
731        genes = widget.Output("Genes", Table)
732
733    want_main_area = True
734    want_control_area = True
735
736    auto_commit = Setting(True)
737    selected_source = Setting("")
738    selected_organism = Setting("")
739    selected_root_attribute = Setting(0)
740
741    settingsHandler = MarkerGroupContextHandler()  # noqa: N815
742    selected_genes = settings.ContextSetting([])
743
744    settings_version = 2
745
746    _data = None
747    _available_sources = None
748
749    def __init__(self) -> None:
750        super().__init__()
751
752        # define the layout
753        main_area = QWidget(self.mainArea)
754        self.mainArea.layout().addWidget(main_area)
755        layout = QGridLayout()
756        main_area.setLayout(layout)
757        layout.setContentsMargins(4, 4, 4, 4)
758
759        # filter line edit
760        self.filter_line_edit = QLineEdit()
761        self.filter_line_edit.setPlaceholderText("Filter marker genes")
762        layout.addWidget(self.filter_line_edit, 0, 0, 1, 3)
763
764        # define available markers view
765        self.available_markers_view = TreeView()
766        box = gui.vBox(self.mainArea, "Available markers", addToLayout=False)
767        box.layout().addWidget(self.available_markers_view)
768        layout.addWidget(box, 1, 0, 2, 1)
769
770        # create selected markers view
771        self.selected_markers_view = TreeView()
772        box = gui.vBox(self.mainArea, "Selected markers", addToLayout=False)
773        box.layout().addWidget(self.selected_markers_view)
774        layout.addWidget(box, 1, 2, 2, 1)
775
776        self.available_markers_view.otherView = self.selected_markers_view
777        self.selected_markers_view.otherView = self.available_markers_view
778
779        # buttons
780        box = gui.vBox(self.mainArea, addToLayout=False, margin=0)
781        layout.addWidget(box, 1, 1, 1, 1)
782        self.move_button = gui.button(box, self, ">", callback=self._move_selected)
783
784        self._init_description_area(layout)
785        self._init_control_area()
786        self._load_data()
787
788    def _init_description_area(self, layout: QLayout) -> None:
789        """
790        Function define an info area with description of the genes and add it to the layout.
791        """
792        box = gui.widgetBox(self.mainArea, "Description", addToLayout=False)
793        self.descriptionlabel = QTextBrowser(
794            openExternalLinks=True, textInteractionFlags=(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
795        )
796        box.setMaximumHeight(self.descriptionlabel.fontMetrics().height() * (NUM_LINES_TEXT + 3))
797
798        # description filed
799        self.descriptionlabel.setText("Select a gene to see information.")
800        self.descriptionlabel.setFrameStyle(QTextBrowser.NoFrame)
801        # no (white) text background
802        self.descriptionlabel.viewport().setAutoFillBackground(False)
803        box.layout().addWidget(self.descriptionlabel)
804        layout.addWidget(box, 3, 0, 1, 3)
805
806    def _init_control_area(self) -> None:
807        """
808        Function defines dropdowns and the button in the control area.
809        """
810        box = gui.widgetBox(self.controlArea, 'Database', margin=0)
811        self.source_index = -1
812        self.db_source_cb = gui.comboBox(box, self, 'source_index')
813        self.db_source_cb.activated[int].connect(self._set_db_source_index)
814
815        box = gui.widgetBox(self.controlArea, 'Organism', margin=0)
816        self.organism_index = -1
817        self.group_cb = gui.comboBox(box, self, 'organism_index')
818        self.group_cb.activated[int].connect(self._set_group_index)
819
820        box = gui.widgetBox(self.controlArea, 'Group by', margin=0)
821        self.group_by_cb = gui.comboBox(
822            box, self, 'selected_root_attribute', items=GROUP_BY_ITEMS, callback=self._setup
823        )
824
825        gui.rubber(self.controlArea)
826        gui.auto_commit(self.controlArea, self, "auto_commit", "Commit")
827
828    def sizeHint(self):
829        return super().sizeHint().expandedTo(QSize(900, 500))
830
831    @property
832    def available_sources(self) -> dict:
833        return self._available_sources
834
835    @available_sources.setter
836    def available_sources(self, value: dict) -> None:
837        """
838        Set _available_sources variable, add them to dropdown, and select the source that was previously.
839        """
840        self._available_sources = value
841
842        items = sorted(list(value.keys()), reverse=True)  # panglao first
843        try:
844            idx = items.index(self.selected_source)
845        except ValueError:
846            idx = -1
847
848        self.db_source_cb.clear()
849        self.db_source_cb.addItems(items)
850
851        if idx != -1:
852            self.source_index = idx
853            self.selected_source = items[idx]
854        elif items:
855            self.source_index = min(max(self.source_index, 0), len(items) - 1)
856
857        self._set_db_source_index(self.source_index)
858
859    @property
860    def data(self) -> Table:
861        return self._data
862
863    @data.setter
864    def data(self, value: Table):
865        """
866        Set the source data. The data is then filtered on the first meta column (group).
867        Select set dropdown with the groups and select the one that was selected previously.
868        """
869        self._data = value
870        domain = value.domain
871
872        if domain.metas:
873            group = domain.metas[0]
874            groupcol, _ = value.get_column_view(group)
875
876            if group.is_string:
877                group_values = list(set(groupcol))
878            elif group.is_discrete:
879                group_values = group.values
880            else:
881                raise TypeError("Invalid column type")
882            group_values = sorted(group_values)  # human first
883
884            try:
885                idx = group_values.index(self.selected_organism)
886            except ValueError:
887                idx = -1
888
889            self.group_cb.clear()
890            self.group_cb.addItems(group_values)
891
892            if idx != -1:
893                self.organism_index = idx
894                self.selected_organism = group_values[idx]
895            elif group_values:
896                self.organism_index = min(max(self.organism_index, 0), len(group_values) - 1)
897
898            self._set_group_index(self.organism_index)
899
900    def _load_data(self) -> None:
901        """
902        Collect available data sources (marker genes data sets).
903        """
904        self.Warning.using_local_files.clear()
905
906        found_sources = {}
907        try:
908            found_sources.update(serverfiles.ServerFiles().allinfo(SERVER_FILES_DOMAIN))
909        except requests.exceptions.ConnectionError:
910            found_sources.update(serverfiles.allinfo(SERVER_FILES_DOMAIN))
911            self.Warning.using_local_files()
912
913        self.available_sources = {item.get('title').split(': ')[-1]: item for item in found_sources.values()}
914
915    def _source_changed(self) -> None:
916        """
917        Respond on change of the source and download the data.
918        """
919        if self.available_sources:
920            file_name = self.available_sources[self.selected_source]['filename']
921
922            try:
923                serverfiles.update(SERVER_FILES_DOMAIN, file_name)
924            except requests.exceptions.ConnectionError:
925                # try to update file. Ignore network errors.
926                pass
927
928            try:
929                file_path = serverfiles.localpath_download(SERVER_FILES_DOMAIN, file_name)
930            except requests.exceptions.ConnectionError as err:
931                # Unexpected error.
932                raise err
933            self.data = Table.from_file(file_path)
934
935    def _setup(self) -> None:
936        """
937        Setup the views with data.
938        """
939        self.closeContext()
940        self.selected_genes = []
941        self.openContext((self.selected_organism, self.selected_source))
942        data_not_selected, data_selected = self._filter_data_group(self.data)
943
944        # add model to available markers view
945        group_by = GROUP_BY_ITEMS[self.selected_root_attribute]
946        tree_model = TreeModel(data_not_selected, group_by)
947        proxy_model = FilterProxyModel(self.filter_line_edit)
948        proxy_model.setSourceModel(tree_model)
949
950        self.available_markers_view.setModel(proxy_model)
951        self.available_markers_view.selectionModel().selectionChanged.connect(
952            partial(self._on_selection_changed, self.available_markers_view)
953        )
954
955        tree_model = TreeModel(data_selected, group_by)
956        proxy_model = FilterProxyModel(self.filter_line_edit)
957        proxy_model.setSourceModel(tree_model)
958        self.selected_markers_view.setModel(proxy_model)
959
960        self.selected_markers_view.selectionModel().selectionChanged.connect(
961            partial(self._on_selection_changed, self.selected_markers_view)
962        )
963        self.selected_markers_view.model().sourceModel().data_added.connect(self._selected_markers_changed)
964        self.selected_markers_view.model().sourceModel().data_removed.connect(self._selected_markers_changed)
965
966        # update output and messages
967        self._selected_markers_changed()
968
969    def _filter_data_group(self, data: Table) -> Tuple[Table, Tuple]:
970        """
971        Function filter the table based on the selected group (Mouse, Human) and divide them in two groups based on
972        selected_data variable.
973
974        Parameters
975        ----------
976        data
977            Table to be filtered
978
979        Returns
980        -------
981        data_not_selected
982            Data that will initially be in available markers view.
983        data_selected
984            Data that will initially be in selected markers view.
985        """
986        group = data.domain.metas[0]
987        gvec = data.get_column_view(group)[0]
988
989        if group.is_string:
990            mask = gvec == self.selected_organism
991        else:
992            mask = gvec == self.organism_index
993        data = data[mask]
994
995        # divide data based on selected_genes variable (context)
996        unique_gene_names = np.core.defchararray.add(
997            data.get_column_view("Entrez ID")[0].astype(str), data.get_column_view("Cell Type")[0].astype(str)
998        )
999        mask = np.isin(unique_gene_names, self.selected_genes)
1000        data_not_selected = data[~mask]
1001        data_selected = data[mask]
1002        return data_not_selected, data_selected
1003
1004    def commit(self) -> None:
1005        rows = self.selected_markers_view.model().sourceModel().rootItem.get_data_rows()
1006        if len(rows) > 0:
1007            metas = [r.metas for r in rows]
1008            data = Table.from_numpy(self.data.domain, np.empty((len(metas), 0)), metas=np.array(metas))
1009            # always false for marker genes data tables in single cell
1010            data.attributes[GENE_AS_ATTRIBUTE_NAME] = False
1011            # set taxonomy id in data.attributes
1012            data.attributes[TAX_ID] = MAP_GROUP_TO_TAX_ID.get(self.selected_organism, '')
1013            # set column id flag
1014            data.attributes[GENE_ID_COLUMN] = "Entrez ID"
1015            data.name = 'Marker Genes'
1016        else:
1017            data = None
1018        self.Outputs.genes.send(data)
1019
1020    def _update_description(self, view: TreeView) -> None:
1021        """
1022        Upate the description about the gene. Only in case when one gene is selected.
1023        """
1024        selection = self._selected_rows(view)
1025        qmodel = view.model().sourceModel()
1026
1027        if len(selection) > 1 or len(selection) == 0 or qmodel.node_from_index(selection[0]).data_row is None:
1028            self.descriptionlabel.setText("Select a gene to see information.")
1029        else:
1030            data_row = qmodel.node_from_index(selection[0]).data_row
1031            self.descriptionlabel.setHtml(
1032                f"<b>Gene name:</b> {data_row['Name']}<br/>"
1033                f"<b>Entrez ID:</b> {data_row['Entrez ID']}<br/>"
1034                f"<b>Cell Type:</b> {data_row['Cell Type']}<br/>"
1035                f"<b>Function:</b> {data_row['Function']}<br/>"
1036                f"<b>Reference:</b> <a href='{data_row['URL']}'>{data_row['Reference']}</a>"
1037            )
1038
1039    def _update_data_info(self) -> None:
1040        """
1041        Updates output info in the control area.
1042        """
1043        sel_model = self.selected_markers_view.model().sourceModel()
1044        self.info.set_output_summary(f"Selected: {str(len(sel_model))}")
1045
1046    # callback functions
1047
1048    def _selected_markers_changed(self) -> None:
1049        """
1050        This function is called when markers in the selected view are added or removed.
1051        """
1052        rows = self.selected_markers_view.model().sourceModel().rootItem.get_data_rows()
1053        self.selected_genes = [row["Entrez ID"].value + row["Cell Type"].value for row in rows]
1054        self._update_data_info()
1055        self.commit()
1056
1057    def _on_selection_changed(self, view: TreeView) -> None:
1058        """
1059        When selection in one of the view changes in a view button should change a sign in the correct direction and
1060        other view should reset the selection. Also gene description is updated.
1061        """
1062        self.move_button.setText(">" if view is self.available_markers_view else "<")
1063        if view is self.available_markers_view:
1064            self.selected_markers_view.clearSelection()
1065        else:
1066            self.available_markers_view.clearSelection()
1067        self._update_description(view)
1068
1069    def _set_db_source_index(self, source_index: int) -> None:
1070        """
1071        Set the index of selected database source - index in a dropdown.
1072        """
1073        self.source_index = source_index
1074        self.selected_source = self.db_source_cb.itemText(source_index)
1075        self._source_changed()
1076
1077    def _set_group_index(self, group_index: int) -> None:
1078        """
1079        Set the index of organism - index in a dropdown.
1080        """
1081        self.organism_index = group_index
1082        self.selected_organism = self.group_cb.itemText(group_index)
1083        self._setup()
1084
1085    def _move_selected(self) -> None:
1086        """
1087        Move selected genes when button clicked.
1088        """
1089        if self._selected_rows(self.selected_markers_view):
1090            self._move_selected_from_to(self.selected_markers_view, self.available_markers_view)
1091        elif self._selected_rows(self.available_markers_view):
1092            self._move_selected_from_to(self.available_markers_view, self.selected_markers_view)
1093
1094    # support functions for callbacks
1095
1096    def _move_selected_from_to(self, src: TreeView, dst: TreeView) -> None:
1097        """
1098        Function moves items from src model to dst model.
1099        """
1100        selected_items = self._selected_rows(src)
1101
1102        src_model = src.model().sourceModel()
1103        dst_model = dst.model().sourceModel()
1104
1105        # move data as mimeData from source to destination tree view
1106        mime_data = src_model.mimeData(selected_items)
1107        # remove nodes from the source view
1108        src_model.remove_node_list(selected_items)
1109        dst_model.dropMimeData(mime_data, Qt.MoveAction, -1, -1)
1110
1111    @staticmethod
1112    def _selected_rows(view: TreeView) -> List[QModelIndex]:
1113        """
1114        Return the selected rows in the view.
1115        """
1116        rows = view.selectionModel().selectedRows()
1117        return list(map(view.model().mapToSource, rows))
1118
1119    @classmethod
1120    def migrate_settings(cls, settings, version=0):
1121        def migrate_to_version_2():
1122            settings["selected_source"] = settings.pop("selected_db_source", "")
1123            settings["selected_organism"] = settings.pop("selected_group", "")
1124            if "context_settings" in settings:
1125                for co in settings["context_settings"]:
1126                    co.values["selected_genes"] = [g[0] + g[1] for g in co.values["selected_genes"]]
1127
1128        if version < 2:
1129            migrate_to_version_2()
1130
1131
1132if __name__ == "__main__":
1133    from orangewidget.utils.widgetpreview import WidgetPreview
1134
1135    WidgetPreview(OWMarkerGenes).run()
1136