Repository: biolab/orange3

Issue:
Hierarchical Clustering needs Data Subset input channel
It would be great if Hierarchical Clustering could show a subset of data. Say, I find some data instances in t-SNE plot, and I would like to know if they have clustered together in HC as well.

One way to depict the subset is to print the labels in bold. If there are no labels, mark the subsetted leaves with a dot.


Discussion:
I implemented this and threw it away. It was ugly.

This widget and a few others (Distance, SilhouettePlot, Heatmap) use`Orange.widgets.utils.graphicstextlist.TextListWidget`, which is a `QGraphicsWidget` that displays uniformly spaced strings stored in a list. I think that a proper way to implement bold labels would be to implement `TextListView` that would get data from a model and would support some roles like font and color. `TextListWidget` would then be derived from `TextListView` and use `PyListModel` to store the list of strings.

In case of no labels, I'd simply label selected rows with `"|"` and perhaps set the color to blue (and whatever's suitable for dark mode).
Note: the widget currently accepts distance matrix as an input. This functionality would work only if distance matrix has some associated data table. If not, passing a subset must show (at least) a warning.

The new signal for subset must not connect automatically, imho, otherwise users will try to connect data that they want clustered, and wonder why the widget does not work. Upon seeing the dialog with connections, they will, hopefully, realize, that they must pass distance matrix and that data can only be used as subset. Also, show a warning (or even an error!) if the user passes a subset but nothing is given as distances.

Resolving patch:
diff --git a/Orange/widgets/unsupervised/owhierarchicalclustering.py b/Orange/widgets/unsupervised/owhierarchicalclustering.py
--- a/Orange/widgets/unsupervised/owhierarchicalclustering.py
+++ b/Orange/widgets/unsupervised/owhierarchicalclustering.py
@@ -8,15 +8,19 @@
 
 from AnyQt.QtWidgets import (
     QGraphicsWidget, QGraphicsScene, QGridLayout, QSizePolicy,
-    QAction, QComboBox, QGraphicsGridLayout, QGraphicsSceneMouseEvent
+    QAction, QComboBox, QGraphicsGridLayout, QGraphicsSceneMouseEvent, QLabel
 )
-from AnyQt.QtGui import QPen, QFont, QKeySequence, QPainterPath
-from AnyQt.QtCore import Qt, QSizeF, QPointF, QRectF, QLineF, QEvent
+from AnyQt.QtGui import QPen, QFont, QKeySequence, QPainterPath, QColor
+from AnyQt.QtCore import Qt, QSizeF, QPointF, QRectF, QLineF, QEvent, \
+    QModelIndex
 from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
 
+from orangewidget.utils.itemmodels import PyListModel
+
 import Orange.data
 from Orange.data.domain import filter_visible
-from Orange.data import Domain
+from Orange.data import Domain, DiscreteVariable, ContinuousVariable, \
+    StringVariable
 import Orange.misc
 from Orange.clustering.hierarchical import \
     postorder, preorder, Tree, tree_from_linkage, dist_matrix_linkage, \
@@ -32,7 +36,7 @@
 from Orange.widgets.widget import Input, Output, Msg
 
 from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView
-from Orange.widgets.utils.graphicstextlist import TextListWidget
+from Orange.widgets.utils.graphicstextlist import TextListView
 from Orange.widgets.utils.dendrogram import DendrogramWidget
 
 __all__ = ["OWHierarchicalClustering"]
@@ -100,6 +104,50 @@ class _DomainContextHandler(settings.DomainContextHandler,
     SelectionState = Tuple[List[Tuple[int]], List[Tuple[int, int, float]]]
 
 
+class SelectedLabelsModel(PyListModel):
+    def __init__(self):
+        super().__init__([])
+        self.subset = set()
+        self.__font = QFont()
+        self.__colors = None
+
+    def rowCount(self, parent=QModelIndex()):
+        count = super().rowCount()
+        if self.__colors is not None:
+            count = max(count, len(self.__colors))
+        return count
+
+    def _emit_data_changed(self):
+        self.dataChanged.emit(self.index(0, 0), self.index(len(self) - 1, 0))
+
+    def set_subset(self, subset):
+        self.subset = set(subset)
+        self._emit_data_changed()
+
+    def set_colors(self, colors):
+        self.__colors = colors
+        self._emit_data_changed()
+
+    def setFont(self, font):
+        self.__font = font
+        self._emit_data_changed()
+
+    def data(self, index, role=Qt.DisplayRole):
+        if role == Qt.FontRole:
+            font = QFont(self.__font)
+            font.setBold(index.row() in self.subset)
+            return font
+        if role == Qt.BackgroundRole:
+            if self.__colors is not None:
+                return self.__colors[index.row()]
+            elif not any(self) and self.subset:  # no labels, no color, but subset
+                return QColor(0, 0, 0)
+        if role == Qt.UserRole and self.subset:
+            return index.row() in self.subset
+
+        return super().data(index, role)
+
+
 class OWHierarchicalClustering(widget.OWWidget):
     name = "Hierarchical Clustering"
     description = "Display a dendrogram of a hierarchical clustering " \
@@ -110,11 +158,13 @@ class OWHierarchicalClustering(widget.OWWidget):
 
     class Inputs:
         distances = Input("Distances", Orange.misc.DistMatrix)
+        subset = Input("Data Subset", Orange.data.Table, explicit=True)
 
     class Outputs:
         selected_data = Output("Selected Data", Orange.data.Table, default=True)
         annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Orange.data.Table)
 
+    settings_version = 2
     settingsHandler = _DomainContextHandler()
 
     #: Selected linkage
@@ -138,16 +188,31 @@ class Outputs:
     top_n = settings.Setting(3)
     #: Dendrogram zoom factor
     zoom_factor = settings.Setting(0)
+    #: Show labels only for subset (if present)
+    label_only_subset = settings.Setting(False)
+    #: Color for label decoration
+    color_by: Union[DiscreteVariable, ContinuousVariable, None] = \
+        settings.ContextSetting(None)
 
     autocommit = settings.Setting(True)
 
     graph_name = "scene"
 
-    basic_annotations = ["None", "Enumeration"]
+    basic_annotations = [None, "Enumeration"]
 
     class Error(widget.OWWidget.Error):
         not_finite_distances = Msg("Some distances are infinite")
 
+    class Warning(widget.OWWidget.Warning):
+        subset_on_no_table = \
+            Msg("Unused data subset: distances do not refer to data instances")
+        subset_not_subset = \
+            Msg("Some data from the subset does not appear in distance matrix")
+        subset_wrong = \
+            Msg("Subset data refers to a different table")
+        pruning_disables_colors = \
+            Msg("Pruned cluster doesn't show colors and indicate subset")
+
     #: Stored (manual) selection state (from a saved workflow) to restore.
     __pending_selection_restore = None  # type: Optional[SelectionState]
 
@@ -156,6 +221,8 @@ def __init__(self):
 
         self.matrix = None
         self.items = None
+        self.subset = None
+        self.subset_rows = set()
         self.linkmatrix = None
         self.root = None
         self._displayed_root = None
@@ -165,10 +232,11 @@ def __init__(self):
             self.controlArea, self, "linkage", items=LINKAGE, box="Linkage",
             callback=self._invalidate_clustering)
 
-        model = itemmodels.VariableListModel()
+        model = itemmodels.VariableListModel(placeholder="None")
         model[:] = self.basic_annotations
 
-        box = gui.widgetBox(self.controlArea, "Annotations")
+        grid = QGridLayout()
+        gui.widgetBox(self.controlArea, "Annotations", orientation=grid)
         self.label_cb = cb = combobox.ComboBoxSearch(
             minimumContentsLength=14,
             sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon
@@ -177,15 +245,34 @@ def __init__(self):
         cb.setCurrentIndex(cb.findData(self.annotation, Qt.EditRole))
 
         def on_annotation_activated():
-            self.annotation = cb.currentData(Qt.EditRole)
+            self.annotation = self.label_cb.currentData(Qt.EditRole)
             self._update_labels()
         cb.activated.connect(on_annotation_activated)
 
         def on_annotation_changed(value):
-            cb.setCurrentIndex(cb.findData(value, Qt.EditRole))
+            self.label_cb.setCurrentIndex(
+                self.label_cb.findData(value, Qt.EditRole))
         self.connect_control("annotation", on_annotation_changed)
 
-        box.layout().addWidget(self.label_cb)
+        grid.addWidget(self.label_cb, 0, 0, 1, 2)
+
+        cb = gui.checkBox(
+            None, self, "label_only_subset", "Show labels only for subset",
+            disabled=True,
+            callback=self._update_labels, stateWhenDisabled=False)
+        grid.addWidget(cb, 1, 0, 1, 2)
+
+        model = itemmodels.DomainModel(
+            valid_types=(DiscreteVariable, ContinuousVariable),
+            placeholder="None")
+        cb = gui.comboBox(
+            None, self, "color_by", orientation=Qt.Horizontal,
+            model=model, callback=self._update_labels,
+            sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding,
+                                   QSizePolicy.Fixed))
+        self.color_by_label = QLabel("Color by:")
+        grid.addWidget(self.color_by_label, 2, 0)
+        grid.addWidget(cb, 2, 1)
 
         box = gui.radioButtons(
             self.controlArea, self, "pruning", box="Pruning",
@@ -296,13 +383,15 @@ def axis_view(orientation):
         self._main_graphics.setLayout(scenelayout)
         self.scene.addItem(self._main_graphics)
 
-        self.dendrogram = DendrogramWidget()
+        self.dendrogram = DendrogramWidget(pen_width=2)
         self.dendrogram.setSizePolicy(QSizePolicy.MinimumExpanding,
                                       QSizePolicy.MinimumExpanding)
         self.dendrogram.selectionChanged.connect(self._invalidate_output)
         self.dendrogram.selectionEdited.connect(self._selection_edited)
 
-        self.labels = TextListWidget()
+        self.labels = TextListView()
+        self.label_model = SelectedLabelsModel()
+        self.labels.setModel(self.label_model)
         self.labels.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
         self.labels.setAlignment(Qt.AlignLeft)
         self.labels.setMaximumWidth(200)
@@ -329,12 +418,6 @@ def axis_view(orientation):
 
     @Inputs.distances
     def set_distances(self, matrix):
-        if self.__pending_selection_restore is not None:
-            selection_state = self.__pending_selection_restore
-        else:
-            # save the current selection to (possibly) restore later
-            selection_state = self._save_selection()
-
         self.error()
         self.Error.clear()
         if matrix is not None:
@@ -348,55 +431,112 @@ def set_distances(self, matrix):
                 matrix = None
 
         self.matrix = matrix
+
+    @Inputs.subset
+    def set_subset(self, subset):
+        self.subset = subset
+        self.controls.label_only_subset.setDisabled(subset is None)
+
+    def handleNewSignals(self):
+        if self.__pending_selection_restore is not None:
+            selection_state = self.__pending_selection_restore
+        else:
+            # save the current selection to (possibly) restore later
+            selection_state = self._save_selection()
+
+        matrix = self.matrix
         if matrix is not None:
             self._set_items(matrix.row_items, matrix.axis)
         else:
             self._set_items(None)
-        self._invalidate_clustering()
+        self._update()
 
         # Can now attempt to restore session state from a saved workflow.
         if self.root and selection_state is not None:
             self._restore_selection(selection_state)
             self.__pending_selection_restore = None
 
+        self.Warning.clear()
+        rows = set()
+        if self.subset:
+            subsetids = set(self.subset.ids)
+            if not isinstance(self.items, Orange.data.Table) \
+                    or not self.matrix.axis:
+                self.Warning.subset_on_no_table()
+            elif (dataids := set(self.items.ids)) and not subsetids & dataids:
+                self.Warning.subset_wrong()
+            elif not subsetids <= dataids:
+                self.Warning.subset_not_subset()
+            else:
+                indices = [leaf.value.index for leaf in leaves(self.root)]
+                rows = {
+                    row for row, rowid in enumerate(self.items.ids[indices])
+                    if rowid in subsetids
+                }
+
+        self.subset_rows = rows
+        self._update_labels()
         self.commit.now()
 
     def _set_items(self, items, axis=1):
         self.closeContext()
         self.items = items
         model = self.label_cb.model()
-        if len(model) == 3:
-            self.annotation_if_names = self.annotation
-        elif len(model) == 2:
-            self.annotation_if_enumerate = self.annotation
+        color_model = self.controls.color_by.model()
+        color_model.set_domain(None)
+        self.color_by = None
+        if len(model) == 2 and model[0] is None:
+            if model[1] == "Name":
+                self.annotation_if_names = self.annotation
+            if model[1] == self.basic_annotations[1]:
+                self.annotation_if_enumerate = self.annotation
         if isinstance(items, Orange.data.Table) and axis:
-            model[:] = chain(
-                self.basic_annotations,
-                [model.Separator],
-                items.domain.class_vars,
-                items.domain.metas,
-                [model.Separator] if (items.domain.class_vars or items.domain.metas) and
-                next(filter_visible(items.domain.attributes), False) else [],
-                filter_visible(items.domain.attributes)
-            )
-            if items.domain.class_vars:
-                self.annotation = items.domain.class_vars[0]
+            metas_class = tuple(
+                filter_visible(chain(items.domain.metas,
+                                     items.domain.class_vars)))
+            visible_attrs = tuple(filter_visible(items.domain.attributes))
+            if not (metas_class or visible_attrs):
+                model[:] = self.basic_annotations
+            else:
+                model[:] = (
+                    (None, )
+                    + metas_class
+                    + (model.Separator, ) * bool(metas_class and visible_attrs)
+                    + visible_attrs)
+            for meta in items.domain.metas:
+                if isinstance(meta, StringVariable):
+                    self.annotation = meta
+                    break
             else:
-                self.annotation = "Enumeration"
+                if items.domain.class_vars:
+                    # No string metas: show class
+                    self.annotation = items.domain.class_vars[0]
+                else:
+                    # No string metas and no class: show the first option
+                    # which is not None (in the worst case, Enumeration)
+                    self.annotation = model[1]
+
+            color_model.set_domain(items.domain)
+            if items.domain.class_vars:
+                self.color_by = items.domain.class_vars[0]
             self.openContext(items.domain)
+        elif isinstance(items, Orange.data.Table) and not axis \
+                or (isinstance(items, list) and \
+                    all(isinstance(var, Orange.data.Variable)
+                        for var in items)):
+            model[:] = (None, "Name")
+            self.annotation = self.annotation_if_names
         else:
-            name_option = bool(
-                items is not None and (
-                    not axis or
-                    isinstance(items, list) and
-                    all(isinstance(var, Orange.data.Variable) for var in items)))
-            model[:] = self.basic_annotations + ["Name"] * name_option
-            self.annotation = self.annotation_if_names if name_option \
-                else self.annotation_if_enumerate
+            model[:] = self.basic_annotations
+            self.annotation = self.annotation_if_enumerate
+
+        no_colors = len(color_model) == 1
+        self.controls.color_by.setDisabled(no_colors)
+        self.color_by_label.setDisabled(no_colors)
 
     def _clear_plot(self):
         self.dendrogram.set_root(None)
-        self.labels.setItems([])
+        self.label_model.clear()
 
     def _set_displayed_root(self, root):
         self._clear_plot()
@@ -439,12 +579,28 @@ def _update(self):
         self._apply_selection()
 
     def _update_labels(self):
+        if not hasattr(self, "label_model"):
+            # This method can be called during widget initialization when
+            # creating check box for label_only_subset, if it's value is
+            # initially True.
+            # See https://github.com/biolab/orange-widget-base/pull/213;
+            # if it's merged, this check can be removed.
+            return
+
+        self.Warning.pruning_disables_colors(
+            shown=self.pruning
+                  and (self.subset_rows or self.color_by is not None))
         labels = []
         if self.root and self._displayed_root:
             indices = [leaf.value.index for leaf in leaves(self.root)]
 
-            if self.annotation == "None":
-                labels = []
+            if self.annotation is None:
+                if not self.pruning \
+                        and self.subset_rows and self.color_by is None:
+                    # Model fails if number of labels and of colors mismatch
+                    labels = [""] * len(indices)
+                else:
+                    labels = []
             elif self.annotation == "Enumeration":
                 labels = [str(i+1) for i in indices]
             elif self.annotation == "Name":
@@ -456,15 +612,27 @@ def _update_labels(self):
                 labels = [labels[idx] for idx in indices]
             else:
                 labels = []
+            if not self.pruning and \
+                    labels and self.label_only_subset and self.subset_rows:
+                labels = [label if row in self.subset_rows else ""
+                          for row, label in enumerate(labels)]
 
             if labels and self._displayed_root is not self.root:
                 joined = leaves(self._displayed_root)
                 labels = [", ".join(labels[leaf.value.first: leaf.value.last])
                           for leaf in joined]
 
-        self.labels.setItems(labels)
+        self.label_model[:] = labels
+        self.label_model.set_subset(set() if self.pruning else self.subset_rows)
         self.labels.setMinimumWidth(1 if labels else -1)
 
+        if not self.pruning and self.color_by is not None:
+            self.label_model.set_colors(
+                self.color_by.palette.values_to_qcolors(
+                    self.items.get_column_view(self.color_by)[0][indices]))
+        else:
+            self.label_model.set_colors(None)
+
     def _restore_selection(self, state):
         # type: (SelectionState) -> bool
         """
@@ -835,6 +1003,7 @@ def __update_font_scale(self):
         factor = (1.25 ** self.zoom_factor)
         font = qfont_scaled(font, factor)
         self._main_graphics.setFont(font)
+        self.label_model.setFont(font)
 
     def send_report(self):
         annot = self.label_cb.currentText()
@@ -855,6 +1024,12 @@ def send_report(self):
         ))
         self.report_plot()
 
+    @classmethod
+    def migrate_context(cls, context, version):
+        if version < 2:
+            if context.values["annotation"] == "None":
+                context.values["annotation"] = None
+
 
 def qfont_scaled(font, factor):
     scaled = QFont(font)
@@ -1028,8 +1203,14 @@ def clusters_at_height(root, height):
     return cluster_list
 
 
-if __name__ == "__main__":  # pragma: no cover
-    from Orange import distance
+def main():
+    # pragma: no cover
+    from Orange import distance  # pylint: disable=import-outside-toplevel
     data = Orange.data.Table("iris")
     matrix = distance.Euclidean(distance._preprocess(data))
-    WidgetPreview(OWHierarchicalClustering).run(matrix)
+    subset = data[10:30]
+    WidgetPreview(OWHierarchicalClustering).run(matrix, set_subset=subset)
+
+
+if __name__ == "__main__":  # pragma: no cover
+    main()
diff --git a/Orange/widgets/utils/dendrogram.py b/Orange/widgets/utils/dendrogram.py
--- a/Orange/widgets/utils/dendrogram.py
+++ b/Orange/widgets/utils/dendrogram.py
@@ -145,7 +145,7 @@ def update_pen(pen, brush=None, width=None, style=None,
     return pen
 
 
-def path_stroke(path, width=1, join_style=Qt.MiterJoin):
+def path_stroke(path, width=1, join_style=Qt.RoundJoin):
     stroke = QPainterPathStroker()
     stroke.setWidth(width)
     stroke.setJoinStyle(join_style)
@@ -153,7 +153,7 @@ def path_stroke(path, width=1, join_style=Qt.MiterJoin):
     return stroke.createStroke(path)
 
 
-def path_outline(path, width=1, join_style=Qt.MiterJoin):
+def path_outline(path, width=1, join_style=Qt.RoundJoin):
     stroke = path_stroke(path, width, join_style)
     return stroke.united(path)
 
@@ -249,6 +249,7 @@ def _update_label_pos(self):
 
     def __init__(self, parent=None, root=None, orientation=Left,
                  hoverHighlightEnabled=True, selectionMode=ExtendedSelection,
+                 *, pen_width=1,
                  **kwargs):
         super().__init__(None, **kwargs)
         # Filter all events from children (`ClusterGraphicsItem`s)
@@ -271,6 +272,7 @@ def __init__(self, parent=None, root=None, orientation=Left,
         self._cluster_parent = {}
         self.__hoverHighlightEnabled = hoverHighlightEnabled
         self.__selectionMode = selectionMode
+        self._pen_width = pen_width
         self.setContentsMargins(0, 0, 0, 0)
         self.setRoot(root)
         if parent is not None:
@@ -348,8 +350,7 @@ def setRoot(self, root):
         self._root = root
         if root is not None:
             foreground = self.palette().color(QPalette.WindowText)
-            pen = make_pen(foreground, width=1, cosmetic=True,
-                           join_style=Qt.MiterJoin)
+            pen = make_pen(foreground, width=self._pen_width, cosmetic=True)
             for node in postorder(root):
                 item = DendrogramWidget.ClusterGraphicsItem(self._itemgroup)
                 item.setAcceptHoverEvents(True)
@@ -447,12 +448,12 @@ def branches(item):
             # Restore the previous item
             highlight = self.palette().color(QPalette.WindowText)
             set_pen(self._highlighted_item,
-                    make_pen(highlight, width=1, cosmetic=True))
+                    make_pen(highlight, width=self._pen_width, cosmetic=True))
 
         self._highlighted_item = item
         if item:
             hpen = make_pen(self.palette().color(QPalette.Highlight),
-                            width=2, cosmetic=True)
+                            width=self._pen_width + 1, cosmetic=True)
             set_pen(item, hpen)
 
     def leafItems(self):
diff --git a/Orange/widgets/utils/graphicstextlist.py b/Orange/widgets/utils/graphicstextlist.py
--- a/Orange/widgets/utils/graphicstextlist.py
+++ b/Orange/widgets/utils/graphicstextlist.py
@@ -2,13 +2,18 @@
 import math
 from typing import Optional, Union, Any, Iterable, List, Callable, cast
 
-from AnyQt.QtCore import Qt, QSizeF, QEvent, QMarginsF, QPointF
-from AnyQt.QtGui import QFont, QFontMetrics, QFontInfo, QPalette
+from AnyQt.QtCore import (
+    Qt, QSizeF, QEvent, QMarginsF, QPointF, QAbstractItemModel, QRect
+)
+from AnyQt.QtGui import (
+    QFont, QFontMetrics, QFontInfo, QPalette, QPixmap, QPainter, QPen
+)
 from AnyQt.QtWidgets import (
     QGraphicsWidget, QSizePolicy, QGraphicsItemGroup, QGraphicsSimpleTextItem,
     QGraphicsItem, QGraphicsScene, QGraphicsSceneResizeEvent, QToolTip,
-    QGraphicsSceneHelpEvent
+    QGraphicsSceneHelpEvent, QGraphicsPixmapItem
 )
+
 from . import apply_all
 from .graphicslayoutitem import scaled
 
@@ -29,32 +34,30 @@ def __len__(self):
         return self.length
 
 
-class TextListWidget(QGraphicsWidget):
+class TextListBase(QGraphicsWidget):
     """
-    A linear text list widget.
+    A base class for linear text list view and widget.
 
     Displays a list of uniformly spaced text lines.
 
     Parameters
     ----------
     parent: Optional[QGraphicsItem]
-    items: Iterable[str]
     alignment: Qt.Alignment
     orientation: Qt.Orientation
     """
     def __init__(
             self,
             parent: Optional[QGraphicsItem] = None,
-            items: Iterable[str] = (),
             alignment: Union[Qt.AlignmentFlag, Qt.Alignment] = Qt.AlignLeading,
             orientation: Qt.Orientation = Qt.Vertical,
             autoScale=False,
             elideMode=Qt.ElideNone,
             **kwargs: Any
     ) -> None:
-        self.__items: List[str] = []
         self.__textitems: List[QGraphicsSimpleTextItem] = []
         self.__group: Optional[QGraphicsItemGroup] = None
+        self.__strip: Optional[QGraphicsPixmapItem] = None
         self.__spacing = 0
         self.__alignment = Qt.AlignmentFlag(alignment)
         self.__orientation = orientation
@@ -78,19 +81,14 @@ def __init__(
         if parent is not None:
             self.setParentItem(parent)
 
-        if items is not None:
-            self.setItems(items)
+    def data(self, row, role=Qt.DisplayRole):
+        raise NotImplementedError
 
-    def setItems(self, items: Iterable[str]) -> None:
-        """
-        Set items for display
+    def count(self):
+        raise NotImplementedError
 
-        Parameters
-        ----------
-        items: Iterable[str]
-        """
+    def reset(self):
         self.__clear()
-        self.__items = list(items)
         self.__widthCache.clear()
         self.__setup()
         self.__layout()
@@ -134,16 +132,9 @@ def clear(self) -> None:
         Remove all items.
         """
         self.__clear()
-        self.__items = []
         self.__widthCache.clear()
         self.updateGeometry()
 
-    def count(self) -> int:
-        """
-        Return the number of items
-        """
-        return len(self.__items)
-
     def indexAt(self, pos: QPointF) -> Optional[int]:
         """
         Return the index of item at `pos`.
@@ -152,11 +143,18 @@ def brect(item):
             return item.mapRectToParent(item.boundingRect())
 
         if self.__orientation == Qt.Vertical:
-            y = lambda pos: pos.y()
+            def y(pos):
+                return pos.y()
         else:
-            y = lambda pos: pos.x()
-        top = lambda idx: brect(items[idx]).top()
-        bottom = lambda idx: brect(items[idx]).bottom()
+            def y(pos):
+                return pos.x()
+
+        def top(idx):
+            return brect(items[idx]).top()
+
+        def bottom(idx):
+            return brect(items[idx]).bottom()
+
         items = self.__textitems
         if not items:
             return None
@@ -185,21 +183,35 @@ def sizeHint(self, which: Qt.SizeHint, constraint=QSizeF()) -> QSizeF:
 
     def __width_for_font(self, font: QFont) -> float:
         """Return item width for the font"""
-        key = font.key()
-        if key in self.__widthCache:
-            return self.__widthCache[key]
-        fm = QFontMetrics(font)
-        width = max((fm.boundingRect(text).width() for text in self.__items),
-                    default=0)
-        self.__widthCache[key] = width
+        if self.data(0, Qt.FontRole) is not None:
+            if None in self.__widthCache:
+                return self.__widthCache[None]
+            width = max(
+                (QFontMetrics(self.data(row, Qt.FontRole))
+                 .boundingRect(self.data(row))
+                 .width()
+                 for row in range(self.count())
+                 ), default=0)
+            self.__widthCache[None] = width
+        else:
+            key = font.key()
+            if key in self.__widthCache:
+                return self.__widthCache[key]
+            fm = QFontMetrics(font)
+            width = max((fm.boundingRect(self.data(row)).width()
+                         for row in range(self.count())),
+                        default=0)
+            self.__widthCache[key] = width
         return width
 
     def __naturalsh(self) -> QSizeF:
         """Return the natural size hint (preferred sh with no constraints)."""
         fm = QFontMetrics(self.font())
         spacing = self.__spacing
-        N = len(self.__items)
+        N = self.count()
         width = self.__width_for_font(self.font())
+        if self.has_color_strip():
+            width += int(round((fm.height() + spacing) * 1.3))
         height = N * fm.height() + max(N - 1, 0) * spacing
         return QSizeF(width, height)
 
@@ -225,7 +237,7 @@ def helpEvent(self, event: QGraphicsSceneHelpEvent):
             viewport = event.widget()
             view = viewport.parentWidget()
             rect = view.mapFromScene(rect).boundingRect()
-            QToolTip.showText(event.screenPos(), self.__items[idx],
+            QToolTip.showText(event.screenPos(), self.data(idx),
                               view, rect)
             event.setAccepted(True)
 
@@ -234,7 +246,7 @@ def changeEvent(self, event):
             self.updateGeometry()
             if self.__autoScale:
                 self.__layout()
-            else:
+            elif self.data(0, Qt.FontRole) is None:
                 font = self.font()
                 apply_all(self.__textitems, lambda it: it.setFont(font))
 
@@ -245,23 +257,10 @@ def changeEvent(self, event):
                 item.setBrush(brush)
         super().changeEvent(event)
 
-    def __setup(self) -> None:
-        self.__clear()
-        font = self.__effectiveFont if self.__autoScale else self.font()
-        assert self.__group is None
-        group = QGraphicsItemGroup()
-        brush = self.palette().brush(QPalette.Text)
-        for text in self.__items:
-            t = QGraphicsSimpleTextItem(group)
-            t.setBrush(brush)
-            t.setFont(font)
-            t.setText(text)
-            t.setData(0, text)
-            self.__textitems.append(t)
-        group.setParentItem(self)
-        self.__group = group
-
     def __layout(self) -> None:
+        if not self.__textitems:
+            return
+
         margins = QMarginsF(*self.getContentsMargins())
         if self.__orientation == Qt.Horizontal:
             # transposed margins
@@ -281,7 +280,7 @@ def __layout(self) -> None:
         if align_horizontal == 0:
             align_horizontal = Qt.AlignLeft
 
-        N = len(self.__items)
+        N = self.count()
 
         if not N:
             return
@@ -306,7 +305,8 @@ def __layout(self) -> None:
             fm = QFontMetrics(font)
             fontheight = fm.height()
 
-        if self.__autoScale and self.__effectiveFont != font:
+        if self.__autoScale and self.__effectiveFont != font \
+                and self.data(0, Qt.FontRole) is None:
             self.__effectiveFont = font
             apply_all(self.__textitems, lambda it: it.setFont(font))
 
@@ -315,7 +315,8 @@ def __layout(self) -> None:
                 textwidth = math.ceil(crect.width())
             else:
                 textwidth = math.ceil(crect.height())
-            for text, item in zip(self.__items, self.__textitems):
+            for row, item in enumerate(self.__textitems):
+                text = self.data(row)
                 textelide = fm.elidedText(
                     text, self.__elideMode, textwidth, Qt.TextSingleLine
                 )
@@ -345,25 +346,179 @@ def __layout(self) -> None:
                     crect.top() + i * advance + align_dy
                 )
 
+        self.__remove_items((self.__strip, ), self.scene())
+        self.__strip = self.__color_strip(round(advance))
+        offset = int(round(advance * 1.3)) if self.__strip else 0
         if self.__orientation == Qt.Vertical:
             self.__group.setRotation(0)
-            self.__group.setPos(0, 0)
+            self.__group.setPos(offset, 0)
+            if self.__strip:
+                self.__strip.setPos(0, 0)
         else:
             self.__group.setRotation(-90)
-            self.__group.setPos(self.rect().bottomLeft())
+            y = self.rect().bottom()
+            self.__group.setPos(0, y - offset)
+            if self.__strip:
+                self.__strip.setPos(0, y)
+
+    def has_color_strip(self):
+        return self.data(0, Qt.BackgroundRole) is not None
+
+    def __color_strip(self, size: int):
+        if not self.has_color_strip() or not size:
+            return None
+        has_selection = self.data(0, Qt.UserRole) is not None
+        margin = int(round(size * 0.2))
+        side = size - 2 * margin
+        pixmap = QPixmap(size, size * self.count())
+        pixmap.fill(Qt.transparent)
+        painter = QPainter()
+        painter.begin(pixmap)
+        painter.setRenderHints(painter.Antialiasing | painter.TextAntialiasing |
+                               painter.SmoothPixmapTransform)
+        for row in range(self.count()):
+            color = self.data(row, Qt.BackgroundRole)
+            painter.setPen(QPen(color, 1))
+            if has_selection and not self.data(row, Qt.UserRole):
+                painter.setBrush(Qt.NoBrush)
+            else:
+                painter.setBrush(color.lighter(140))
+            rect = QRect(margin, margin + row * size, side, side)
+            painter.drawRect(rect)
+        painter.end()
+        return QGraphicsPixmapItem(pixmap, self)
 
     def __clear(self) -> None:
-        def remove(items: Iterable[QGraphicsItem],
-                   scene: Optional[QGraphicsScene]):
-            for item in items:
-                if scene is not None:
-                    scene.removeItem(item)
-                else:
-                    item.setParentItem(None)
         self.__textitems = []
-        if self.__group is not None:
-            remove([self.__group], self.scene())
-            self.__group = None
+        self.__remove_items((self.__group, self.__strip), self.scene())
+        self.__group = self.__strip = None
+
+    @staticmethod
+    def __remove_items(items: Iterable[QGraphicsItem],
+                       scene: Optional[QGraphicsScene]):
+        for item in items:
+            if item is None:
+                continue
+            if scene is not None:
+                scene.removeItem(item)
+            else:
+                item.setParentItem(None)
+
+    def __setup(self) -> None:
+        self.__clear()
+        font = self.__effectiveFont if self.__autoScale else self.font()
+        assert self.__group is None
+        group = QGraphicsItemGroup()
+        brush = self.palette().brush(QPalette.Text)
+        for row in range(self.count()):
+            text = self.data(row)
+            t = QGraphicsSimpleTextItem(group)
+            t.setBrush(self.data(row, Qt.ForegroundRole) or brush)
+            t.setFont(self.data(row, Qt.FontRole) or font)
+            t.setText(text)
+            t.setData(0, text)
+            t.setToolTip(self.data(row, Qt.ToolTipRole))
+            self.__textitems.append(t)
+        group.setParentItem(self)
+        self.__group = group
+
+
+class TextListWidget(TextListBase):
+    """
+    A linear text list widget.
+
+    Displays a list of uniformly spaced text lines.
+
+    Parameters
+    ----------
+    parent: Optional[QGraphicsItem]
+    items: Iterable[str]
+    alignment: Qt.Alignment
+    orientation: Qt.Orientation
+    """
+    def __init__(
+            self,
+            parent: Optional[QGraphicsItem] = None,
+            items: Iterable[str] = (),
+            alignment: Union[Qt.AlignmentFlag, Qt.Alignment] = Qt.AlignLeading,
+            orientation: Qt.Orientation = Qt.Vertical,
+            autoScale=False,
+            elideMode=Qt.ElideNone,
+            **kwargs: Any
+    ) -> None:
+        self.__items: List[str] = []
+        super().__init__(parent, alignment, orientation, autoScale, elideMode,
+                         **kwargs)
+        if items is not None:
+            self.setItems(items)
+
+    def setItems(self, items: Iterable[str]) -> None:
+        self.__items = list(items)
+        self.reset()
+
+    def clear(self) -> None:
+        """Remove all items."""
+        self.__items = []
+        super().clear()
+
+    def count(self) -> int:
+        """Return the number of items"""
+        return len(self.__items)
+
+    def data(self, row, role=Qt.DisplayRole):
+        if row < len(self.__items):
+            if role == Qt.DisplayRole:
+                return self.__items[row]
+        return None
+
+
+class TextListView(TextListBase):
+    """
+    A linear text list view.
+
+    Displays a list of uniformly spaced text lines.
+
+    Parameters
+    ----------
+    parent: Optional[QGraphicsItem]
+    items: Iterable[str]
+    alignment: Qt.Alignment
+    orientation: Qt.Orientation
+    """
+    def __init__(
+            self,
+            parent: Optional[QGraphicsItem] = None,
+            alignment: Union[Qt.AlignmentFlag, Qt.Alignment] = Qt.AlignLeading,
+            orientation: Qt.Orientation = Qt.Vertical,
+            autoScale=False,
+            elideMode=Qt.ElideNone,
+            **kwargs: Any
+    ) -> None:
+        self.__model = None
+        super().__init__(parent, alignment, orientation, autoScale, elideMode,
+                         **kwargs)
+
+    def setModel(self, model: QAbstractItemModel) -> None:
+        self.__model = model
+        self.reset()
+        model.dataChanged.connect(self.reset)
+        model.rowsInserted.connect(self.reset)
+        model.rowsRemoved.connect(self.reset)
+        model.rowsMoved.connect(self.reset)
+        model.modelReset.connect(self.reset)
+
+    def model(self):
+        return self.__model
+
+    def count(self) -> int:
+        if self.__model is None:
+            return 0
+        return self.__model.rowCount()
+
+    def data(self, row, role=Qt.DisplayRole):
+        if self.__model is None:
+            return None
+        return self.__model.index(row, 0).data(role)
 
 
 def effective_point_size_for_height(


Test patch: